Compare commits

...
44 Commits
Author SHA1 Message Date
rustmailer 44fc0e15de bump versions 2025-12-31 22:57:31 +08:00
rustmailer ef891b20c3 fix(account): update sync range and handle all-mode reset 2025-12-31 22:57:20 +08:00
rustmailer a6216c2ce6 feat: support restoring single message to IMAP #77 2025-12-31 22:56:06 +08:00
rustmailer 1c58b516dd update sign-out dialog 2025-12-31 02:44:43 +08:00
rustmailer ae916574de Fix: modifying the admin user 2025-12-31 02:43:57 +08:00
rustmailer 14fb3368a3 udpate locales files 2025-12-31 02:40:58 +08:00
rustmailer f49929dd67 Update message.rs 2025-12-30 22:41:51 +08:00
rustmailerandGitHub 97143d55b8 Merge pull request #67 from mmaudet/feat/envelope-endpoint-and-api-improvements
feat(api): Add envelope endpoint and improve API documentation
2025-12-30 22:32:26 +08:00
rustmailerandGitHub e666f76d87 Merge branch 'main' into feat/envelope-endpoint-and-api-improvements 2025-12-30 22:32:11 +08:00
rustmailer 7fb6575f8d feat: use email 'Date' header for statistics and search filtering #87 2025-12-30 22:21:53 +08:00
rustmailer fb0be8c5d1 bump version to 0.2.1 2025-12-30 15:11:57 +08:00
rustmailer 455e6b1a75 feat: support user appearance preferences with persisted theme and language #85 2025-12-30 15:09:41 +08:00
rustmailer 75cae51be9 feat(ui): Add quick page navigation to the email list pagination #85 2025-12-30 11:40:09 +08:00
rustmailer 62d956c7d6 feat: increase password max to 256, fix i18n, and force re-login #83
- Raise password maximum length from 32 to 256 characters
- fix profileSchema to accept `t` for proper internationalization
- Invalidate user's WebUI token on password change, requiring re-login
2025-12-30 11:05:07 +08:00
rustmailer c01872284e Update release.yml 2025-12-29 12:41:15 +08:00
rustmailer 2887b5d16d Update release.yml 2025-12-29 12:37:51 +08:00
rustmailer 558ea2f9b0 Update release.yml 2025-12-29 12:25:05 +08:00
rustmailer b07defa2d5 Update README.md 2025-12-29 12:22:05 +08:00
rustmailer 76ab16b55b fetch: support fetching mails before a specified date 2025-12-29 12:06:04 +08:00
rustmailer 06a126461b feat: Replace min/max byte inputs with size preset selection #39 2025-12-28 13:54:35 +08:00
rustmailer a02bb65ca0 feat: Search results display the account email and mailbox name. #39 2025-12-28 13:20:21 +08:00
rustmailer 1f57f372d3 feat: Add sync_batch_size to allow users to customize the synchronization batch size, and introduce date_before to support semantics such as downloading emails from more than one year ago. #24 #58 2025-12-28 13:01:34 +08:00
rustmailer b35493e4e1 chore(search ui): Quick selection of year and month #39 2025-12-28 12:58:30 +08:00
rustmailer 16578fb8e2 fix: stitch adjacent RFC2047 words to prevent byte-split artifacts #79 2025-12-27 03:38:53 +08:00
rustmailer 6dd3f90ee0 update 2025-12-26 20:04:31 +08:00
rustmailerandGitHub 6d11dcd33f Merge pull request #65 from mmaudet/fix/rename-id-to-message-id
fix(api): Rename id to message_id and fix OpenAPI path parameters
2025-12-26 20:00:48 +08:00
rustmailerandGitHub e64c2467fd Merge branch 'main' into fix/rename-id-to-message-id 2025-12-26 19:59:25 +08:00
rustmailer e8a15695d8 feat(ui): add i18n support for profile dropdown 2025-12-26 14:45:43 +08:00
rustmailer 0f3ad83004 feat: use password file as primary source if provided 2025-12-26 14:44:49 +08:00
rustmailer 4af5176b65 feat: add multi-user support and role-based access control #31 2025-12-26 14:27:04 +08:00
rustmailer 1e2f526a07 fix: ensure unselected checkboxes are visible in dark mode #70 2025-12-26 14:17:55 +08:00
rustmailerandGitHub 97be76278e Merge pull request #71 from metlos/encrypt-password-file
feat(cli): add an option to specify the encrypt password in a file
2025-12-20 19:45:55 +08:00
rustmailerandGitHub d4232789f9 Add roadmap section to README #76
Added a roadmap section outlining future features and enhancements.
2025-12-20 18:59:52 +08:00
Lukas Krejci 9b83d5617e feat(cli): add an option to specify the encrypt password in a file 2025-12-17 18:03:00 +01:00
Michel-Marie MAUDETandClaude Opus 4.5 70db81dc03 feat(api): Add envelope endpoint and improve API documentation
- Add GET /envelope/{account_id}/{message_id} endpoint to retrieve message envelope (metadata)
- Add get_envelope_by_id method to ENVELOPE_INDEX_MANAGER for querying single envelope
- Move message_id from query parameter to path parameter for clearer API paths:
  - /message-content/{account_id}/{message_id}
  - /download-message/{account_id}/{message_id}
  - /download-attachment/{account_id}/{message_id}
  - /envelope/{account_id}/{message_id}
- Fix API documentation descriptions to be more accurate:
  - search_messages: Now correctly describes search functionality
  - get_thread_messages: Mentions thread_id requirement
  - proxy endpoints: Fixed copy-paste errors from OAuth2 docs
- Update frontend API client to use new path-based URLs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:12:16 +01:00
Michel-Marie MAUDETandClaude Opus 4.5 6b18d7371d fix(api): Use poem_openapi::param::Path for OpenAPI documentation
- Fix Path import in message.rs, account.rs, mailbox.rs, oauth2.rs,
  and auto_config.rs to use poem_openapi::param::Path instead of
  poem::web::Path
- This ensures path parameters appear in OpenAPI/Swagger documentation
- Update frontend API calls to use message_id parameter
- Add parameter documentation comments for better API clarity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 09:33:18 +01:00
Michel-Marie MAUDETandClaude Opus 4.5 934e81c5f9 fix(api): Rename query parameter id to message_id for clarity
Rename the `id` query parameter to `message_id` in three message API
endpoints for better API clarity and consistency:

- GET /api/v1/message-content/:account_id
- GET /api/v1/download-message/:account_id
- GET /api/v1/download-attachment/:account_id

This is a breaking change for API clients that use these endpoints.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 08:02:02 +01:00
rustmailerandGitHub c05a8944ef Merge pull request #57 from pansuzuki/lang-pack-pl
Added new language Polish
2025-12-10 23:13:04 +08:00
Marcin Wojtczak eaff2ca70d Added new language Polish 2025-12-10 00:21:16 +01:00
rustmailer 000d144e70 chore(ui): Adjust width of account list for better visibility 2025-12-08 03:43:00 +08:00
rustmailer 20970b4fb6 Feat: Add IMAP connection pool status logging and disable bb8 idle timeout 2025-12-08 03:41:56 +08:00
rustmailer 57df3466e7 Update extractor.rs 2025-12-08 02:18:34 +08:00
rustmailer 75d859abdf fix(sync): error in account sync task "TooNarrow" #38 2025-12-08 01:30:46 +08:00
rustmailerandGitHub b0412b02f5 Add user case showcase and performance data overview
Added a user case showcase highlighting performance and storage efficiency with data from 126 email accounts.
2025-12-07 23:22:54 +08:00
230 changed files with 28630 additions and 3973 deletions
+16
View File
@@ -34,6 +34,22 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify Cargo.toml version matches git tag
shell: bash
run: |
TAG_VERSION="${GITHUB_REF_NAME}"
CARGO_VERSION=$(grep '^version' Cargo.toml | head -n1 | cut -d '"' -f2)
echo "Git tag version: $TAG_VERSION"
echo "Cargo.toml version: $CARGO_VERSION"
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
echo "::error::Version mismatch! Git tag ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
exit 1
fi
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
Generated
+26 -20
View File
@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "bichon"
version = "0.1.3"
version = "0.2.2"
dependencies = [
"ahash",
"async-imap",
@@ -1537,9 +1537,9 @@ dependencies = [
[[package]]
name = "governor"
version = "0.10.2"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e23d5986fd4364c2fb7498523540618b4b8d92eec6c36a02e565f66748e2f79"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
@@ -1686,9 +1686,9 @@ dependencies = [
[[package]]
name = "html2text"
version = "0.16.4"
version = "0.16.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1418e1f5886c4a3ac23a22a5590a4907935f13edb0526593dc02a769eecefdbe"
checksum = "89f3074c1e4a7c4b4f7aca411a610e0f2f27e16d571d0e584524b66c93204100"
dependencies = [
"html5ever",
"tendril",
@@ -2131,9 +2131,9 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.15"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jobserver"
@@ -3498,9 +3498,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.2"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
@@ -3536,9 +3536,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.13.1"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c"
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
dependencies = [
"web-time",
"zeroize",
@@ -3667,15 +3667,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.148"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@@ -4295,9 +4295,9 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand 2.3.0",
"getrandom 0.3.4",
@@ -4633,9 +4633,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.43"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
@@ -4668,9 +4668,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.35"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
@@ -5727,6 +5727,12 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "zmij"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3280a1b827474fcd5dbef4b35a674deb52ba5c312363aef9135317df179d81b"
[[package]]
name = "zstd"
version = "0.13.3"
+8 -8
View File
@@ -1,6 +1,6 @@
[package]
name = "bichon"
version = "0.1.4"
version = "0.2.2"
edition = "2021"
[[bin]]
@@ -37,9 +37,9 @@ poem-openapi = { version = "5.1.16", features = [
] }
ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
serde_json = "1.0.148"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1.43"
tracing = "0.1.44"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
base64 = "0.22.1"
@@ -83,11 +83,11 @@ async-imap = { version = "0.11.1", default-features = false, features = [
] }
webpki-roots = "1.0.4"
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
rustls-pki-types = "1.13.1"
rustls-pki-types = "1.13.2"
tokio-io-timeout = "1.2.1"
bb8 = "0.9.1"
semver = "1.0.27"
governor = "0.10.2"
governor = "0.10.4"
lru = "0.16.2"
mime_guess = "2.0.5"
hex = "0.4.3"
@@ -105,10 +105,10 @@ dashmap = "6.1.0"
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
gethostname = "1.1.0"
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
itoa = "1.0.15"
html2text = "0.16.4"
itoa = "1.0.17"
html2text = "0.16.5"
bytes = "1.11.0"
[dev-dependencies]
#bincode = "1.3.3"
#secret-lib = "1.0.0"
tempfile = "3.23.0"
tempfile = "3.24.0"
+95 -97
View File
@@ -52,49 +52,20 @@ Built in Rust, it requires no external dependencies and provides fast, efficient
## 🚀 Features
### ⚡ Lightweight & Standalone
- Pure Rust, single-machine application.
- No external database required.
- Includes **WebUI** for intuitive management.
* **Lightweight & Standalone** — Pure Rust, no external database, with built-in WebUI
* **Multi-Account Sync** — Download and manage emails from multiple accounts
* **Flexible Fetching** — Sync by date range, email count, or specific mailboxes
* **IMAP & OAuth2 Auth** — Password or OAuth2 login with automatic token refresh
* **Proxy & Auto Config** — Supports network proxies and automatic IMAP discovery
* **Unified Search** — Search across all accounts by sender, subject, body, date, size, attachments, and more
* **Tags & Facets** — Organize emails using Tantivy facet-based tags
* **Compressed Storage** — Transparent compression and deduplication for efficient storage
* **Email Management** — Browse, view threads, bulk clean up, export EML or attachments
* **Dashboard & Analytics** — Visual insights into email volume, trends, and top senders
* **Internationalized WebUI** — Frontend available in 18 languages
* **OpenAPI Access** — OpenAPI docs with access-token authentication
* **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions
### 📬 Multi-Account Management
- Synchronize and download emails from multiple accounts.
- Flexible selection: by **date range**, **number of emails**, or **specific mailboxes**.
### 🔑 IMAP & OAuth2 Authentication
- Supports **IMAP password** or **OAuth2** login.
- Built-in WebUI for **OAuth2 authorization**, including **automatic token refresh** (e.g., Gmail, Outlook).
- Supports **network proxy** for IMAP and OAuth2.
- Automatic IMAP server discovery and configuration.
### 🔍 Unified Multi-Account Search
- Powerful search across all accounts:
**account**, **mailbox**, **sender**, **attachment name**, **has attachments**, **size**, **date**, **subject**, **body**.
### 🏷️ Tags & Facets
- Organize archived emails using **tags** backed by Tantivy **facets**.
- Efficiently filter and locate emails based on these facet-based tags.
### 💾 Compressed & Deduplicated Storage
- Store emails efficiently with **transparent compression** and **deduplication**—emails can be read directly without any extra steps.
### 📂 Email Management & Viewing
- Bulk cleanup of local archives.
- Download emails as **EML** or **attachments separately**.
- View and browse emails directly.
- View the full **conversation thread** of any email.
### 📊 Dashboard & Analytics
- Visualize email statistics: **counts**, **time distribution**, **top senders**, **largest emails**, **account rankings**.
### 🌐 Internationalization (i18n)
* WebUI fully supports **17 languages** for all interface elements.
* Backend responses (e.g., system messages, API data) are **not yet internationalized**.
* Frontend is ready to support more languages in the future with minimal effort.
### 🛠️ OpenAPI Support
- Provides **OpenAPI documentation**.
- **Access token authentication** for programmatic access.
## 🐾 Why Create Bichon?
@@ -285,79 +256,53 @@ Extract and run:
* If you are accessing Bichon from a proxy domain **mydomain** argument --bichon-cors-origins="https://mydomain" is required.
## Setting the Bichon Encryption Password
## 🔐 Setting the Bichon Encryption Password
Bichon uses an encryption password to secure sensitive data. **You must set it before first use**, when no data exists.
Please refer to the following documentation for detailed instructions on how to set the Bichon encryption password:
Once set, it **cannot be changed**. Changing it later will make all encrypted data unreadable. To start over, you would need to **reinitialize Bichon and clear all emails and metadata**.
👉 [https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password](https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password)
### How to Set the Password
All configuration methods, including command-line options, environment variables, and password file support (v0.2.0+), are documented there.
You can set the password **via command-line or environment variable**:
## 🔑 User Authentication & Admin Account
### Command-Line
Starting from **Bichon v0.2.0**, the authentication model has been updated.
```bash
bichon --bichon-encrypt-password "your-strong-password"
```
### Built-in Admin User (v0.2.0+)
### Environment Variable
* Bichon no longer uses the legacy single-account `root / root` login.
* The system now ships with a built-in **admin** user by default.
* **Default credentials:**
```bash
export BICHON_ENCRYPT_PASSWORD="your-strong-password"
bichon
```
* **Username:** `admin`
* **Password:** `admin@bichon`
**Tip:** Use a strong, secure password and keep it safe, as it cannot be changed later.
> The legacy `root` account and the `root / root` default credentials **no longer exist**.
## 🔑 Root User Login Information
**Bichon currently supports a single Root user login for system access and management.**
### Mandatory Access Token Authentication
### First Login and Enabling Access
* From **v0.2.0 onward**, **access-tokenbased authentication is always enabled**.
* The startup flag and environment variable
`--bichon-enable-access-token` / `BICHON_ENABLE_ACCESS_TOKEN`
are **deprecated and no longer used**.
* No additional configuration is required to enable authentication.
To enable the login feature, you must specify a command-line argument or set an environment variable when starting Bichon.
#### 1\. Command-Line Argument
### Managing Account Information
Add the `--bichon-enable-access-token` flag to your startup command:
After logging in, the admin user can manage their profile directly in the WebUI:
```bash
# Linux/macOS Binary Deployment Example
./bichon --bichon-root-dir /tmp/bichon-data --bichon-enable-access-token
```
1. Log in to the WebUI using the default admin credentials.
2. Navigate to **Settings → Profile**.
3. Update:
#### 2\. Environment Variable (Recommended for Docker)
Set the environment variable `BICHON_ENABLE_ACCESS_TOKEN` to `true`:
```bash
# Docker Deployment Example
docker run -d \
--name bichon \
-p 15630:15630 \
-v $(pwd)/bichon-data:/data \
-e BICHON_LOG_LEVEL=info \
-e BICHON_ROOT_DIR=/data \
-e BICHON_ENABLE_ACCESS_TOKEN=true \
rustmailer/bichon:latest
```
### Default Credentials
* **Initial Login Account:** `root`
* **Initial Password:** `root`
### Changing the Password
**It is strongly recommended that you change the default password immediately after your first login.**
You can change the password via the WebUI:
1. Log in to the WebUI.
2. Navigate to the **Settings** page.
3. Use the **Reset Root Password** option to modify your password.
* Username
* Password
* Avatar and other profile information
⚠️ **Security Notice:**
For security reasons, you should **change the default admin password immediately after the first login**.
## 📖 Documentation
@@ -371,6 +316,56 @@ please see the FAQ in the project Wiki:
👉 [https://github.com/rustmailer/bichon/wiki/FAQ](https://github.com/rustmailer/bichon/wiki/FAQ-(Frequently-Asked-Questions))
## 💡 User Case Showcase
We have collected a real-world case study from a user processing email data, which demonstrates Bichon's performance and storage efficiency in a live environment.
This case involves ingesting and indexing data from **126 email accounts**. The total original data volume was **229 GB**, comprising **460,000 emails**.
### 📊 Performance Data Overview
<img width="945" height="582" alt="image" src="https://github.com/user-attachments/assets/934ed6dd-c1da-4483-84fa-6d5b1bf6ca72" />
A special thank you to **[@rallisf1](https://github.com/rallisf1)** for sharing this usage scenario and the detailed data.
#### 🤝 Open Invitation
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
- **Backend**: Rust + Poem
@@ -433,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
+4 -4
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use mimalloc::MiMalloc;
use modules::{
common::rustls::RustMailerTls,
@@ -25,11 +24,12 @@ use modules::{
logger,
rest::start_http_server,
tasks::PeriodicTasks,
token::root::ensure_root_token,
};
use tracing::info;
use crate::modules::{common::signal::SignalManager, settings::dir::DataDirManager};
use crate::modules::{
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
};
mod modules;
@@ -68,7 +68,7 @@ async fn initialize() -> BichonResult<()> {
// SETTINGS.validate()?;
SignalManager::initialize().await?;
DataDirManager::initialize().await?;
ensure_root_token().await?;
UserManager::initialize().await?;
RustMailerTls::initialize().await?;
EmailClientExecutors::initialize().await?;
PeriodicTasks::start_background_tasks();
+160
View File
@@ -0,0 +1,160 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{
modules::{
account::migration::AccountModel,
common::auth::ClientContext,
database::{manager::DB_MANAGER, with_transaction},
error::{code::ErrorCode, BichonResult},
users::{
permissions::Permission,
role::{RoleType, UserRole},
UserModel,
},
},
raise_error, utc_now,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct BatchAccountRoleRequest {
pub account_ids: Vec<u64>,
pub user_ids: Vec<u64>,
pub role_id: u64,
}
impl BatchAccountRoleRequest {
pub async fn validate_existence(&self) -> BichonResult<()> {
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
raise_error!(
format!("Role ID {} not found", self.role_id),
ErrorCode::ResourceNotFound
)
})?;
if !matches!(role.role_type, RoleType::Account) {
return Err(raise_error!(
"Only Account roles can be assigned to individual account".into(),
ErrorCode::InvalidParameter
));
}
for id in &self.account_ids {
let exists = AccountModel::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("Account ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
for id in &self.user_ids {
let exists = UserModel::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("User ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
Ok(())
}
async fn grant_batch_account_access(
account_ids: Vec<u64>,
user_ids: Vec<u64>,
role_id: u64,
) -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
for &uid in &user_ids {
// Fetch the current user record from the database
let user = rw
.get()
.primary::<UserModel>(uid)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", uid),
ErrorCode::ResourceNotFound
)
})?;
let mut updated_user = user.clone();
// Apply the role to each specified account_id
for &aid in &account_ids {
updated_user.account_access_map.insert(aid, role_id);
}
updated_user.updated_at = utc_now!();
// Save the updated user back to the database within the transaction
rw.update(user, updated_user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await
}
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
for account_id in &self.account_ids {
// Get the user's specific access for this account
let assigned_role_id =
context
.user
.account_access_map
.get(account_id)
.ok_or_else(|| {
raise_error!(
format!("No access to account {}", account_id),
ErrorCode::Forbidden
)
})?;
// Fetch the role definition from the database
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
raise_error!(
"Assigned account role no longer exists".into(),
ErrorCode::InternalError
)
})?;
// Critical Check: Does this role grant management/sharing rights?
if !user_scoped_role
.permissions
.contains(Permission::ACCOUNT_MANAGE)
{
return Err(raise_error!(
format!("Your role on account {} does not allow sharing", account_id),
ErrorCode::Forbidden
));
}
// Optional: Ensure manager isn't giving away perms they don't have
// This is where you'd compare target_role.permissions vs manager's perms
}
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
}
}
+157 -30
View File
@@ -27,11 +27,16 @@ use tracing::info;
use crate::{
encrypt,
modules::{
account::{entity::ImapConfig, since::DateSince, state::AccountRunningState},
account::{
entity::ImapConfig,
since::{DateSince, RelativeDate},
state::AccountRunningState,
},
cache::imap::mailbox::MailBox,
database::{insert_impl, list_all_impl},
database::{list_all_impl, with_transaction},
error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID},
},
utc_now,
};
@@ -52,10 +57,9 @@ use crate::modules::database::{
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::rest::response::DataPage;
use crate::modules::token::AccessToken;
use crate::raise_error;
pub type AccountModel = AccountV2;
pub type AccountModel = AccountV3;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AccountType {
@@ -121,8 +125,42 @@ impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 3, from = AccountV2)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV3 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub sync_batch_size: Option<u32>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV3 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
id: id!(64),
email: request.email,
@@ -141,12 +179,15 @@ impl AccountV2 {
folder_limit: request.folder_limit,
use_dangerous: request.use_dangerous,
pgp_key: request.pgp_key,
created_by: user_id,
sync_batch_size: request.sync_batch_size,
date_before: request.date_before,
})
}
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
let account =
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
.await?
.ok_or_else(|| {
raise_error!(
@@ -154,13 +195,6 @@ impl AccountV2 {
ErrorCode::ResourceNotFound
)
})?;
// if !account.enabled {
// return Err(raise_error!(
// format!("Account id='{account_id}' is disabled"),
// ErrorCode::AccountDisabled
// ));
// }
Ok(account)
}
@@ -176,24 +210,48 @@ impl AccountV2 {
}
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
.await
}
/// Saves the current `AccountEntity` by persisting it to storage.
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
pub async fn create_account(
user_id: u64,
request: AccountCreateRequest,
) -> BichonResult<AccountModel> {
let entity = request.create_entity(user_id)?;
let cloned = entity.clone();
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let account_id = entity.id;
rw.insert::<AccountModel>(entity)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let user = rw
.get()
.primary::<UserModel>(user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", user_id),
ErrorCode::ResourceNotFound
)
})?;
pub async fn create_account(request: AccountCreateRequest) -> BichonResult<AccountModel> {
let entity = request.create_entity()?;
entity.save().await?;
if matches!(entity.account_type, AccountType::IMAP) {
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 +288,7 @@ impl AccountV2 {
async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
}).await
}
@@ -242,7 +300,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 +318,7 @@ impl AccountV2 {
sync_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -275,7 +333,7 @@ impl AccountV2 {
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -290,7 +348,7 @@ impl AccountV2 {
capabilities: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -319,7 +377,7 @@ impl AccountV2 {
}
pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id)
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id)
.await
}
@@ -342,6 +400,19 @@ 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(clear_date_range) = request.clear_date_range {
if clear_date_range {
new.date_since = None;
new.date_before = None;
}
}
if let Some(folder_limit) = request.folder_limit {
@@ -377,6 +448,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 +526,54 @@ impl From<AccountV2> for AccountV1 {
}
}
}
impl From<AccountV3> for AccountV2 {
fn from(value: AccountV3) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
impl From<AccountV2> for AccountV3 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: DEFAULT_ADMIN_USER_ID,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
sync_batch_size: None,
date_before: None,
}
}
}
+3 -2
View File
@@ -16,10 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod dispatcher;
pub mod entity;
pub mod grant;
pub mod migration;
pub mod payload;
pub mod since;
pub mod state;
pub mod migration;
pub mod view;
+47 -8
View File
@@ -16,14 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::modules::account::entity::ImapConfig;
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::account::since::DateSince;
use crate::modules::account::since::{DateSince, RelativeDate};
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::token::AccountInfo;
use crate::{raise_error, validate_email};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
@@ -36,21 +33,37 @@ pub struct AccountCreateRequest {
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub account_type: AccountType,
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>,
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountCreateRequest {
pub fn create_entity(self) -> BichonResult<AccountModel> {
pub fn create_entity(self, user_id: u64) -> BichonResult<AccountModel> {
if self.date_before.is_some() && self.date_since.is_some() {
return Err(raise_error!(
"date_before and date_since are mutually exclusive; specify only one time boundary"
.into(),
ErrorCode::InvalidParameter
));
}
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
if let Some(date_before) = self.date_before.as_ref() {
date_before.validate_date()?;
}
match self.account_type {
AccountType::IMAP => {
match &self.imap {
@@ -71,7 +84,7 @@ impl AccountCreateRequest {
}
AccountType::NoSync => {}
}
Ok(AccountModel::new(self)?)
Ok(AccountModel::new(user_id, self)?)
}
fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> {
@@ -107,6 +120,8 @@ pub struct AccountUpdateRequest {
/// - First-time sync optimization for large accounts
/// - Reducing server load during resyncs
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub clear_date_range: Option<bool>,
/// 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 +144,8 @@ pub struct AccountUpdateRequest {
/// Incremental sync interval (seconds)
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
/// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook).
/// - If `None` or not provided, the client will connect directly to the API server.
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
@@ -141,9 +158,31 @@ 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 self.clear_date_range == Some(true)
&& (self.date_since.is_some() || self.date_before.is_some())
{
return Err(raise_error!(
"clear_date_range cannot be combined with date_since or date_before".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 +206,11 @@ pub struct MinimalAccount {
pub fn filter_accessible_accounts<'a>(
all_accounts: &'a [MinimalAccount],
allowed: &BTreeSet<AccountInfo>,
allowed: &Vec<u64>,
) -> Vec<MinimalAccount> {
all_accounts
.iter()
.filter(|acct| allowed.iter().any(|a| a.id == acct.id))
.filter(|acct| allowed.contains(&acct.id))
.cloned()
.collect()
}
-1
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::error::{code::ErrorCode, BichonResult},
raise_error,
+91
View File
@@ -0,0 +1,91 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::{BTreeSet, HashMap};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
account::{
entity::ImapConfig,
migration::{AccountModel, AccountType},
since::{DateSince, RelativeDate},
},
users::UserModel,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AccountResp {
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub sync_batch_size: Option<u32>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub created_user_name: String,
pub created_user_email: String,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountResp {
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, UserModel>) -> AccountResp {
let user = user_map.get(&account.created_by);
AccountResp {
id: account.id,
imap: account.imap,
enabled: account.enabled,
email: account.email,
name: account.name,
capabilities: account.capabilities,
date_since: account.date_since,
date_before: account.date_before,
folder_limit: account.folder_limit,
sync_folders: account.sync_folders,
account_type: account.account_type,
sync_interval_min: account.sync_interval_min,
sync_batch_size: account.sync_batch_size,
known_folders: account.known_folders,
created_at: account.created_at,
updated_at: account.updated_at,
created_by: account.created_by,
created_user_name: user
.map(|u| u.username.clone())
.unwrap_or_else(|| "Unknown".to_string()),
created_user_email: user
.map(|u| u.email.clone())
.unwrap_or_else(|| "N/A".to_string()),
use_proxy: account.use_proxy,
use_dangerous: account.use_dangerous,
pgp_key: account.pgp_key,
}
}
}
+82 -21
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, state::AccountRunningState},
@@ -24,7 +23,7 @@ use crate::{
imap::{
find_intersecting_mailboxes, find_missing_mailboxes,
mailbox::MailBox,
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_since_date},
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
},
SEMAPHORE,
},
@@ -37,17 +36,30 @@ use crate::{
use std::time::Instant;
use tracing::{debug, error, info, warn};
pub const BATCH_SIZE: u32 = 50;
pub const DEFAULT_BATCH_SIZE: u32 = 50;
pub async fn fetch_and_save_since_date(
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum FetchDirection {
Since,
Before,
}
pub async fn fetch_and_save_by_date(
account: &AccountModel,
date: &str,
mailbox: &MailBox,
direction: FetchDirection,
) -> BichonResult<usize> {
let account_id = account.id;
let executor = MAIL_CONTEXT.imap(account_id).await?;
let search_criteria = match direction {
FetchDirection::Since => format!("SINCE {date}"),
FetchDirection::Before => format!("BEFORE {date}"),
};
let uid_list = executor
.uid_search(&mailbox.encoded_name(), format!("SINCE {date}").as_str())
.uid_search(&mailbox.encoded_name(), &search_criteria)
.await?;
let len = uid_list.len();
@@ -63,13 +75,23 @@ pub async fn fetch_and_save_since_date(
if let Some(limit) = folder_limit {
let limit = limit.max(100) as usize;
if len > limit {
uid_vec = uid_vec.split_off(len - limit as usize);
uid_vec = match direction {
FetchDirection::Since => uid_vec.split_off(len - limit),
FetchDirection::Before => {
uid_vec.truncate(limit);
uid_vec
}
};
}
}
// let semaphore = Arc::new(Semaphore::new(5));
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
AccountRunningState::set_initial_current_syncing_folder(
account_id,
mailbox.name.clone(),
@@ -105,9 +127,11 @@ pub async fn fetch_and_save_full_mailbox(
_ => total,
};
let page_size = if let Some(limit) = folder_limit {
limit.max(100).min(BATCH_SIZE as u32)
limit
.max(100)
.min(account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE))
} else {
BATCH_SIZE as u32
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE)
};
let total_batches = total_to_fetch.div_ceil(page_size);
@@ -251,17 +275,30 @@ pub async fn reconcile_mailboxes(
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_since_date(
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
date_since,
&date_since.since_date()?,
remote_mailbox,
FetchDirection::Since,
)
.await?;
}
None => {
rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?;
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
&r.calculate_date()?,
remote_mailbox,
FetchDirection::Before,
)
.await?;
}
None => {
rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?
}
},
}
} else {
perform_incremental_sync(account, local_mailbox, remote_mailbox).await?;
@@ -305,14 +342,31 @@ pub async fn reconcile_mailboxes(
let _permit = permit;
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_since_date(
&account, mailbox.id, date_since, &mailbox,
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&date_since.since_date()?,
&mailbox,
FetchDirection::Since,
)
.await
}
None => {
rebuild_mailbox_cache(&account, &mailbox, &mailbox).await
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&r.calculate_date()?,
&mailbox,
FetchDirection::Before,
)
.await
}
None => {
rebuild_mailbox_cache(&account, &mailbox, &mailbox)
.await
}
},
}
});
handles.push(handle);
@@ -348,8 +402,14 @@ async fn perform_incremental_sync(
match local_max_uid {
Some(max_uid) => {
let executor = MAIL_CONTEXT.imap(account.id).await?;
let before_date = account
.date_before
.as_ref()
.map(|r| r.calculate_date())
.transpose()?;
executor
.fetch_new_mail(account.id, local_mailbox, max_uid + 1)
.fetch_new_mail(account, local_mailbox, max_uid + 1, before_date.as_deref())
.await?;
}
None => {
@@ -359,10 +419,11 @@ async fn perform_incremental_sync(
match &account.date_since {
Some(date_since) => {
fetch_and_save_since_date(
fetch_and_save_by_date(
account,
date_since.since_date()?.as_str(),
remote_mailbox,
FetchDirection::Since,
)
.await?;
}
+21 -4
View File
@@ -23,13 +23,13 @@ use crate::{
migration::{AccountModel, AccountType},
state::AccountRunningState,
},
cache::imap::mailbox::MailBox,
cache::imap::{mailbox::MailBox, sync::flow::FetchDirection},
error::BichonResult,
},
utc_now,
};
use flow::reconcile_mailboxes;
use rebuild::{rebuild_cache, rebuild_cache_since_date};
use rebuild::{rebuild_cache, rebuild_cache_by_date};
use std::time::Instant;
use sync_folders::get_sync_folders;
use sync_type::{determine_sync_type, SyncType};
@@ -54,9 +54,26 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
// AccountRunningState::set_initial_sync_start(account_id).await?;
let result = match &account.date_since {
Some(date_since) => {
rebuild_cache_since_date(account, &remote_mailboxes, date_since).await
rebuild_cache_by_date(
account,
&remote_mailboxes,
&date_since.since_date()?,
FetchDirection::Since,
)
.await
}
None => rebuild_cache(account, &remote_mailboxes).await,
None => match &account.date_before {
Some(r) => {
rebuild_cache_by_date(
account,
&remote_mailboxes,
&r.calculate_date()?,
FetchDirection::Before,
)
.await
}
None => rebuild_cache(account, &remote_mailboxes).await,
},
};
match result {
Ok(_) => {
+18 -14
View File
@@ -16,14 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, since::DateSince},
account::migration::AccountModel,
cache::{
imap::{
mailbox::MailBox,
sync::flow::{fetch_and_save_full_mailbox, fetch_and_save_since_date},
sync::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection},
},
SEMAPHORE,
},
@@ -86,14 +85,14 @@ pub async fn rebuild_cache(
Ok(())
}
pub async fn rebuild_cache_since_date(
pub async fn rebuild_cache_by_date(
account: &AccountModel,
remote_mailboxes: &[MailBox],
date_since: &DateSince,
date: &str,
direction: FetchDirection,
) -> BichonResult<()> {
let start_time = Instant::now();
let mut total_inserted = 0;
let date = date_since.since_date()?;
MailBox::batch_insert(remote_mailboxes).await?;
let mut handles = Vec::new();
@@ -107,13 +106,14 @@ pub async fn rebuild_cache_since_date(
}
let account = account.clone();
let mailbox = mailbox.clone();
let date = date.clone();
let date = date.to_string();
let direction = direction.clone();
match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => {
let handle: tokio::task::JoinHandle<Result<usize, BichonError>> =
tokio::spawn(async move {
let _permit = permit; // Ensure permit is released when task finishes
fetch_and_save_since_date(&account, date.as_str(), &mailbox).await
fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction).await
});
handles.push(handle);
}
@@ -132,10 +132,14 @@ pub async fn rebuild_cache_since_date(
}
}
let elapsed_time = start_time.elapsed().as_secs();
let direction_desc = match direction {
FetchDirection::Since => "starting from the specified date",
FetchDirection::Before => "ending before the specified date",
};
info!(
"Rebuild account cache completed: {} envelopes inserted. {} secs elapsed. \
Data fetched from server starting from the specified date: {}.",
total_inserted, elapsed_time, date
Data fetched from server {}: {}.",
total_inserted, elapsed_time, direction_desc, date
);
Ok(())
}
@@ -169,11 +173,12 @@ pub async fn rebuild_mailbox_cache(
Ok(())
}
pub async fn rebuild_mailbox_cache_since_date(
pub async fn rebuild_mailbox_cache_by_date(
account: &AccountModel,
local_mailbox_id: u64,
date_since: &DateSince,
date: &str,
remote: &MailBox,
direction: FetchDirection,
) -> BichonResult<()> {
ENVELOPE_INDEX_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
@@ -190,8 +195,7 @@ pub async fn rebuild_mailbox_cache_since_date(
return Ok(()); // Skip if the mailbox has no emails
}
let count =
fetch_and_save_since_date(account, date_since.since_date()?.as_str(), remote).await?;
let count = fetch_and_save_by_date(account, date, remote, direction).await?;
info!(
"Account {}: Successfully rebuild mailbox cache, inserted {} envelopes for mailbox '{}'.",
account.id, count, &remote.name
+153 -133
View File
@@ -16,12 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
settings::{cli::SETTINGS, system::SystemSetting},
token::{root::ROOT_TOKEN, AccessToken, AccountInfo},
token::AccessTokenModel,
users::{permissions::Permission, role::UserRole, UserModel},
utils::rate_limit::RATE_LIMITER_MANAGER,
},
raise_error,
@@ -35,7 +34,11 @@ use poem::{
Endpoint, FromRequest, Middleware, Request, RequestBody, Result,
};
use serde::Deserialize;
use std::{collections::BTreeSet, net::IpAddr, sync::Arc};
use std::{
collections::{BTreeSet, HashSet},
net::IpAddr,
sync::Arc,
};
use super::create_api_error_response;
@@ -68,62 +71,101 @@ impl<E: Endpoint> Endpoint for ApiGuardEndpoint<E> {
}
}
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct ClientContext {
pub ip_addr: Option<IpAddr>,
pub access_token: Option<AccessToken>,
pub is_root: bool,
pub user: UserModel,
}
impl ClientContext {
pub fn require_root(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(())
} else {
Err(raise_error!(
"Root access required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_authorized(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root || self.access_token.is_some() {
Ok(())
} else {
Err(raise_error!(
"Authorization required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_account_access(&self, account_id: u64) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
return Ok(());
}
match &self.access_token {
Some(token) if token.can_access_account(account_id) => Ok(()),
_ => Err(raise_error!(format!(
"You do not have permission to access the requested email account (ID: {}). Please check your access rights or contact the administrator.",
account_id
), ErrorCode::PermissionDenied)),
}
}
pub fn accessible_accounts(&self) -> BichonResult<Option<&BTreeSet<AccountInfo>>> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(None) // All accounts are accessible
} else {
match &self.access_token {
Some(token) => Ok(Some(&token.accounts)),
None => Err(raise_error!(
"Missing access token".into(),
ErrorCode::PermissionDenied
)),
pub async fn require_any_permission(
&self,
requirements: Vec<(Option<u64>, &str)>,
) -> BichonResult<()> {
for (account_id, permission) in requirements {
if self.has_permission(account_id, permission).await {
return Ok(());
}
}
Err(raise_error!(
"Access denied: Insufficient permissions to perform this action.".into(),
ErrorCode::Forbidden
))
}
pub async fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
if self.user.is_admin().await {
return true;
}
let mut global_perms = HashSet::new();
for rid in &self.user.global_roles {
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
global_perms.extend(role.permissions);
}
}
if self.check_global_logic(&global_perms, permission) {
return true;
}
if let Some(aid) = account_id {
if let Some(role_id) = self.user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| self.check_account_logic(&role.permissions, permission)
{
return true;
}
}
}
}
false
}
fn check_global_logic(&self, global: &HashSet<String>, perm: &str) -> bool {
if global.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ => global.contains(Permission::DATA_READ_ALL),
Permission::DATA_DELETE => global.contains(Permission::DATA_DELETE_ALL),
Permission::DATA_RAW_DOWNLOAD => global.contains(Permission::DATA_RAW_DOWNLOAD_ALL),
Permission::DATA_EXPORT_BATCH => global.contains(Permission::DATA_EXPORT_BATCH_ALL),
Permission::ACCOUNT_MANAGE | Permission::ACCOUNT_READ_DETAILS => {
global.contains(Permission::ACCOUNT_MANAGE_ALL)
}
_ => false,
}
}
fn check_account_logic(&self, scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
if scoped_perms.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ | Permission::ACCOUNT_READ_DETAILS => {
scoped_perms.contains(Permission::ACCOUNT_MANAGE)
}
_ => false,
}
}
pub async fn require_permission(
&self,
account_id: Option<u64>,
permission: &str,
) -> BichonResult<()> {
if self.has_permission(account_id, permission).await {
Ok(())
} else {
Err(raise_error!(
format!("Access Denied: Missing permission '{}'", permission),
ErrorCode::Forbidden
))
}
}
}
@@ -134,98 +176,76 @@ impl<'a> FromRequest<'a> for ClientContext {
}
pub async fn extract_client_context(req: &Request) -> Result<ClientContext> {
if SETTINGS.bichon_enable_access_token {
let ip_addr = RealIp::from_request_without_body(req)
.await
.map_err(|_| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?
.0
.ok_or_else(|| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?;
// Extract access token from Bearer header or query params
let bearer = req
.headers()
.typed_get::<Authorization<Bearer>>()
.map(|auth| auth.0.token().to_string())
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
let ip_addr = RealIp::from_request_without_body(req)
.await
.map_err(|_| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?
.0
.ok_or_else(|| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?;
// Extract access token from Bearer header or query params
let bearer = req
.headers()
.typed_get::<Authorization<Bearer>>()
.map(|auth| auth.0.token().to_string())
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
let token = bearer.ok_or_else(|| {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
let token = bearer.ok_or_else(|| {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
})?;
// Validate and update access token
let user = AccessTokenModel::resolve_user_from_token(&token)
.await
.map_err(|e| {
create_api_error_response(&format!("{:#?}", e), ErrorCode::PermissionDenied)
})?;
// Check for root token
if let Ok(Some(root)) = SystemSetting::get(ROOT_TOKEN) {
if root.value == token {
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: None,
is_root: true,
});
}
}
// Validate and update access token
let validated_token = AccessToken::try_update_access_timestamp(&token)
.await
.map_err(|_| {
create_api_error_response("Invalid access token", ErrorCode::PermissionDenied)
})?;
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: Some(validated_token),
is_root: false,
});
}
Ok(Default::default())
return Ok(ClientContext {
ip_addr: Some(ip_addr),
user,
});
}
pub async fn authorize_access(req: &Request) -> Result<ClientContext, poem::Error> {
let context = extract_client_context(&req).await?;
context.require_authorized().map_err(|error| {
create_api_error_response(&error.to_string(), ErrorCode::PermissionDenied)
})?;
if let Some(access_token) = &context.access_token {
if let Some(access_control) = &access_token.acl {
if let Some(ip_addr) = context.ip_addr {
if let Some(whitelist) = &access_control.ip_whitelist {
if !whitelist.contains(&ip_addr.to_string()) {
return Err(create_api_error_response(
&format!("IP {} not in whitelist", ip_addr),
ErrorCode::PermissionDenied,
));
}
}
}
if let Some(rate_limit) = &access_control.rate_limit {
if let Err(not_until) = RATE_LIMITER_MANAGER
.check(&access_token.token, rate_limit.clone())
.await
{
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
if let Some(access_control) = &context.user.acl {
if let Some(ip_addr) = context.ip_addr {
if let Some(whitelist) = &access_control.ip_whitelist {
if !whitelist.contains(&ip_addr.to_string()) {
return Err(create_api_error_response(
&format!(
"Rate limit: {}/{}s. Retry after {}s",
rate_limit.quota,
rate_limit.interval,
wait_duration.as_secs()
),
ErrorCode::TooManyRequest,
&format!("IP {} not in whitelist", ip_addr),
ErrorCode::Forbidden,
));
}
}
}
if let Some(rate_limit) = &access_control.rate_limit {
if let Err(not_until) = RATE_LIMITER_MANAGER
.check(context.user.id, rate_limit.clone())
.await
{
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
return Err(create_api_error_response(
&format!(
"Rate limit: {}/{}s. Retry after {}s",
rate_limit.quota,
rate_limit.interval,
wait_duration.as_secs()
),
ErrorCode::TooManyRequest,
));
}
}
}
Ok(context)
+2 -2
View File
@@ -25,7 +25,7 @@ use crate::modules::error::code::ErrorCode;
use super::create_api_error_response;
pub const TIMEOUT_HEADER: &str = "X-RustMailer-Timeout-Seconds";
pub const TIMEOUT_HEADER: &str = "X-Bichon-Timeout-Seconds";
pub struct Timeout;
@@ -63,7 +63,7 @@ impl<E: Endpoint> Endpoint for TimeoutEndpoint<E> {
error!("Request timed out after {} seconds", seconds);
Err(create_api_error_response(
&format!(
"Request timed out after {} seconds (timeout set via X-RustMailer-Timeout-Seconds header, max allowed: 600 seconds)",
"Request timed out after {} seconds (timeout set via X-Bichon-Timeout-Seconds header, max allowed: 600 seconds)",
seconds
),
ErrorCode::RequestTimeout,
+1 -1
View File
@@ -63,7 +63,7 @@ impl EmailClientExecutors {
}
let pool = build_imap_pool(account_id).await?;
let new_executor = Arc::new(ImapExecutor::new(pool));
let new_executor = Arc::new(ImapExecutor::new(account_id, pool));
match self.imap.try_entry(account_id) {
Some(dashmap::mapref::entry::Entry::Occupied(entry)) => Ok(entry.get().clone()),
+42 -9
View File
@@ -16,14 +16,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::users::permissions::Permission;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tantivy::{schema::Value, TantivyDocument};
use crate::{
bichon_version,
modules::{
account::migration::AccountModel,
common::auth::ClientContext,
error::{code::ErrorCode, BichonResult},
indexer::{manager::ENVELOPE_INDEX_MANAGER, schema::SchemaTools},
settings::dir::DATA_DIR_MANAGER,
@@ -50,17 +53,47 @@ pub struct DashboardStats {
}
impl DashboardStats {
pub async fn get() -> BichonResult<Self> {
let mut stat = ENVELOPE_INDEX_MANAGER.get_dashboard_stats().await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER.top_10_largest_emails().await?;
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails()?;
stat.account_count = AccountModel::count().await?;
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
pub async fn get(context: ClientContext) -> BichonResult<Self> {
let has_all_accounts = context
.has_permission(None, Permission::ACCOUNT_MANAGE_ALL)
.await;
let authorized_ids: Option<HashSet<u64>> = if has_all_accounts {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
let mut stat = ENVELOPE_INDEX_MANAGER
.get_dashboard_stats(&authorized_ids)
.await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER
.top_10_largest_emails(&authorized_ids)
.await?;
stat.account_count = if has_all_accounts {
AccountModel::count().await?
} else {
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
};
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails(&authorized_ids)?;
if has_all_accounts {
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
} else {
stat.storage_usage_bytes = 0;
stat.index_usage_bytes = 0;
}
stat.system_version = bichon_version!().to_string();
stat.commit_hash = env!("GIT_HASH").to_string();
Ok(stat)
}
}
+3
View File
@@ -21,6 +21,7 @@ use crate::modules::cache::imap::MAILBOX_MODELS;
use crate::modules::error::{code::ErrorCode, BichonError};
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::dir::DATA_DIR_MANAGER;
use crate::modules::users::UserModel;
use crate::modules::{database::META_MODELS, error::BichonResult};
use crate::raise_error;
use native_db::{Builder, Database};
@@ -73,6 +74,8 @@ impl DatabaseManager {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<UserModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
+46 -19
View File
@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::{AccountV1, AccountV2};
use crate::modules::account::migration::{AccountV1, AccountV2, AccountV3};
use crate::modules::autoconfig::CachedMailSettings;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -25,7 +25,9 @@ use crate::modules::oauth2::pending::OAuth2PendingEntity;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::system::SystemSetting;
use crate::modules::token::AccessToken;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::role::UserRole;
use crate::modules::users::{BichonUser, BichonUserV2};
use crate::raise_error;
use db_type::{KeyOptions, ToKeyDefinition};
use itertools::Itertools;
@@ -58,15 +60,21 @@ impl ModelsAdapter {
}
pub fn register_metadata_models(&mut self) {
self.register_model::<AccessToken>();
//Starting from version 0.2.0, `AccessToken` is deprecated/no longer used, but its ID must not be reused, otherwise it may cause model errors.
//self.register_model::<AccessToken>();
self.register_model::<SystemSetting>();
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<AccountV2>();
self.register_model::<AccountV3>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();
self.register_model::<Proxy>();
self.register_model::<UserRole>();
self.register_model::<BichonUser>();
self.register_model::<BichonUserV2>();
self.register_model::<AccessTokenModel>();
}
}
@@ -170,11 +178,11 @@ pub async fn update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let current_item = current(&rw)?;
let updated_item = updated(&current_item)?;
rw.update(current_item.clone(), updated_item)
rw.update(current_item, updated_item.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(current_item)
Ok(updated_item)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
@@ -223,20 +231,20 @@ pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub fn find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key: &str,
) -> BichonResult<Option<T>> {
let db = database.clone();
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: Option<T> = r_transaction
.get()
.primary(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entity)
}
// pub fn find_impl<T: ToInput + Clone + Send + 'static>(
// database: &Arc<Database<'static>>,
// key: &str,
// ) -> BichonResult<Option<T>> {
// let db = database.clone();
// let r_transaction = db
// .r_transaction()
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// let entity: Option<T> = r_transaction
// .get()
// .primary(key)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(entity)
// }
pub async fn delete_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
@@ -307,6 +315,25 @@ pub async fn list_all_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn with_transaction(
database: &Arc<Database<'static>>,
f: impl FnOnce(&RwTransaction) -> BichonResult<()> + Send + 'static,
) -> BichonResult<()> {
let db: Arc<Database<'_>> = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
f(&rw_transaction)?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
// For tables with a creation timestamp, place the creation time at the front of the primary key.
// This allows sorting by time, as the data is stored in dictionary order based on the primary key.
// If reverse sorting by time is needed, the iterator can be reversed.
+74 -6
View File
@@ -17,14 +17,14 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::AddrVec;
use crate::modules::envelope::utils::normalize_subject;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::utils::create_hash;
use crate::{calculate_hash, raise_error, utc_now};
use crate::{id, modules::indexer::envelope::Envelope};
use async_imap::types::Fetch;
use html2text::from_read;
use mail_parser::{Message, MessageParser, MimeHeaders};
use mail_parser::{HeaderName, Message, MessageParser, MimeHeaders};
pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> BichonResult<Envelope> {
let internal_date = fetch
@@ -48,7 +48,9 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
from_read(html.as_bytes(), 0)
html2text::config::plain()
.allow_width_overflow()
.string_from_read(html.as_bytes(), 100)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} else {
String::new()
@@ -61,7 +63,13 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let subject = message.subject().map(String::from).unwrap_or("".into());
let mut subject = message.subject().map(String::from).unwrap_or_default();
if subject.contains('\u{FFFD}') {
subject = normalize_subject(message.header_raw(HeaderName::Subject));
}
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
@@ -113,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)
}
@@ -134,7 +144,9 @@ pub fn extract_envelope_from_eml(
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
from_read(html.as_bytes(), 0)
html2text::config::plain()
.allow_width_overflow()
.string_from_read(html.as_bytes(), 100)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} else {
String::new()
@@ -147,7 +159,12 @@ pub fn extract_envelope_from_eml(
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let subject = message.subject().map(String::from).unwrap_or("".into());
let mut subject = message.subject().map(String::from).unwrap_or_default();
if subject.contains('\u{FFFD}') {
subject = normalize_subject(message.header_raw(HeaderName::Subject));
}
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
@@ -199,6 +216,8 @@ pub fn extract_envelope_from_eml(
thread_id,
attachments,
tags: None,
account_email: None,
mailbox_name: None,
};
Ok(envelope)
}
@@ -229,3 +248,52 @@ fn extract_references(message: &Message<'_>) -> Option<Vec<String>> {
_ => None,
}
}
#[cfg(test)]
mod test {
use html2text::config;
#[test]
fn test_various_html_with_overflow_enabled() {
let cases = [
("<p>Hello World</p>", "Simple paragraph"),
("<h1>Title</h1><p>Content</p>", "Heading + paragraph"),
("<ul><li>Item1</li><li>Item2</li></ul>", "Unordered list"),
(
"<strong>Bold</strong> and <em>italic</em>",
"Inline formatting",
),
(
"<div><span>Nested</span> elements</div>",
"Nested inline elements inside block",
),
(
"<table><tr><td>A</td><td>B</td></tr></table>",
"Simple table",
),
(
"<pre> preformatted text\n line2</pre>",
"Preformatted block",
),
("😃 emoji test", "Wide emoji"),
("<a href=\"#\">link</a>", "Anchor tag"),
(
"<blockquote><p>Quoted text</p></blockquote>",
"Blockquote with paragraph",
),
];
for (html, desc) in cases {
let result = config::plain()
.allow_width_overflow()
.string_from_read(html.as_bytes(), 100);
match result {
Ok(output) => {
println!("✓ Rendered ({}) =>\n{}", desc, output);
}
Err(e) => panic!("Unexpected error for {}: {:?}", desc, e),
}
}
}
}
+1 -1
View File
@@ -16,5 +16,5 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod extractor;
pub mod utils;
+114
View File
@@ -0,0 +1,114 @@
use mail_parser::parsers::MessageStream;
use regex::{Captures, Regex};
fn merge_contiguous_encoded_words(input: &str) -> String {
let block_re =
Regex::new(r"(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=)(?:\s+(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=))+")
.unwrap();
let word_re = Regex::new(r"=\?([^?]+)\?([bBqQ])\?([^?]+)\?=").unwrap();
block_re
.replace_all(input, |caps: &Captures| {
let whole = caps.get(0).unwrap().as_str();
let mut charset: Option<String> = None;
let mut encoding: Option<String> = None;
let mut combined = String::new();
let mut ok = true;
for cap in word_re.captures_iter(whole) {
let cs = &cap[1];
let enc = cap[2].to_ascii_uppercase();
let text = &cap[3];
if let Some(ref c) = charset {
if c != cs {
ok = false;
break;
}
} else {
charset = Some(cs.to_string());
}
if let Some(ref e) = encoding {
if e != &enc {
ok = false;
break;
}
} else {
encoding = Some(enc);
}
combined.push_str(text);
}
if ok {
format!(
"=?{}?{}?{}?=",
charset.unwrap(),
encoding.unwrap(),
combined
)
} else {
whole.to_string()
}
})
.to_string()
}
pub fn normalize_subject(raw_subject: Option<&str>) -> String {
let subject = match raw_subject {
Some(subject) => merge_contiguous_encoded_words(subject),
None => return String::new(),
};
MessageStream::new(subject.as_bytes())
.parse_unstructured()
.as_text()
.map(String::from)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use crate::modules::envelope::utils::merge_contiguous_encoded_words;
#[tokio::test]
async fn test3() {
let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!";
assert_eq!(
merge_contiguous_encoded_words(s),
"Hello =?UTF-8?B?SGVsbG8=V29ybGQ=?= !!!"
);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= =?UTF-8?B?Qw==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==Qw==?="
);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= test =?UTF-8?B?Qw==?= =?UTF-8?B?RA==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==?= test =?UTF-8?B?Qw==RA==?="
);
let s = "=?UTF-8?B?QQ==?= =?GBK?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
let s = "Hello =?UTF-8?B?SGVsbG8=?= !!!";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
let s = "Just a normal subject line";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?World?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?Q?Hello_World?=");
}
}
+6 -2
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem::http::StatusCode;
use poem_openapi::Enum;
@@ -34,12 +33,14 @@ pub enum ErrorCode {
// Authentication and authorization errors (2000020999)
PermissionDenied = 20000,
AccountDisabled = 20010,
Forbidden = 20020,
OAuth2ItemDisabled = 20050,
MissingRefreshToken = 20060,
// Resource errors (3000030999)
ResourceNotFound = 30000,
TooManyRequest = 30020,
AlreadyExists = 30030,
// Network connection errors (4000040999)
NetworkError = 40000,
@@ -64,11 +65,14 @@ impl ErrorCode {
| ErrorCode::MissingConfiguration
| ErrorCode::Incompatible => StatusCode::BAD_REQUEST,
ErrorCode::PermissionDenied => StatusCode::UNAUTHORIZED,
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled => StatusCode::FORBIDDEN,
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled | ErrorCode::Forbidden => {
StatusCode::FORBIDDEN
}
ErrorCode::ResourceNotFound => StatusCode::NOT_FOUND,
ErrorCode::RequestTimeout => StatusCode::REQUEST_TIMEOUT,
ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
ErrorCode::TooManyRequest => StatusCode::TOO_MANY_REQUESTS,
ErrorCode::AlreadyExists => StatusCode::CONFLICT,
ErrorCode::InternalError
| ErrorCode::AutoconfigFetchFailed
| ErrorCode::ImapCommandFailed
+1 -16
View File
@@ -16,15 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{fmt::Formatter, u32};
use crate::raise_error;
use bb8::RunError;
use code::ErrorCode;
use poem::http::StatusCode;
use poem_openapi::{payload::Json, ApiResponse, Object};
use snafu::{Location, Snafu};
use std::{fmt::Formatter, u32};
pub mod code;
pub mod handler;
@@ -43,17 +39,6 @@ pub enum BichonError {
pub type BichonResult<T, E = BichonError> = std::result::Result<T, E>;
impl From<RunError<BichonError>> for BichonError {
fn from(e: RunError<BichonError>) -> Self {
match e {
RunError::User(e) => e,
RunError::TimedOut => raise_error!(
"Timed out while attempting to acquire a connection from the pool".into(),
ErrorCode::ConnectionPoolTimeout
),
}
}
}
#[derive(Debug, Clone, Object)]
pub struct ApiError {
pub message: String,
+81 -23
View File
@@ -16,10 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::account::state::AccountRunningState;
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, BATCH_SIZE};
use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE};
use crate::modules::envelope::extractor::extract_envelope;
use crate::modules::error::code::ErrorCode;
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
@@ -27,7 +27,7 @@ use crate::modules::indexer::schema::SchemaTools;
use crate::modules::{error::BichonResult, imap::manager::ImapConnectionManager};
use crate::raise_error;
use async_imap::types::{Mailbox, Name};
use bb8::Pool;
use bb8::{Pool, RunError};
use futures::TryStreamExt;
use std::collections::HashSet;
use tantivy::doc;
@@ -36,16 +36,17 @@ use tracing::info;
const BODY_FETCH_COMMAND: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[])";
pub struct ImapExecutor {
account_id: u64,
pool: Pool<ImapConnectionManager>,
}
impl ImapExecutor {
pub fn new(pool: Pool<ImapConnectionManager>) -> Self {
Self { pool }
pub fn new(account_id: u64, pool: Pool<ImapConnectionManager>) -> Self {
Self { account_id, pool }
}
pub async fn list_all_mailboxes(&self) -> BichonResult<Vec<Name>> {
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
let list = session
.list(Some(""), Some("*"))
.await
@@ -58,7 +59,7 @@ impl ImapExecutor {
}
pub async fn examine_mailbox(&self, mailbox_name: &str) -> BichonResult<Mailbox> {
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
session
.examine(mailbox_name)
.await
@@ -66,7 +67,7 @@ impl ImapExecutor {
}
pub async fn uid_search(&self, mailbox_name: &str, query: &str) -> BichonResult<HashSet<u32>> {
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
session
.examine(mailbox_name)
.await
@@ -78,19 +79,35 @@ impl ImapExecutor {
Ok(result)
}
pub async fn append(
&self,
mailbox_name: impl AsRef<str>,
flags: Option<&str>,
internaldate: Option<&str>,
content: impl AsRef<[u8]>,
) -> BichonResult<()> {
let mut session = self.get_connection().await?;
session
.append(mailbox_name, flags, internaldate, content)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
}
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 uid_list = self
.uid_search(
&mailbox.encoded_name(),
format!("UID {start_uid}:*").as_str(),
)
.await?;
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(), &query).await?;
let len = uid_list.len();
if len == 0 {
@@ -98,17 +115,21 @@ impl ImapExecutor {
}
info!(
"[account {}][mailbox {}] {} envelopes need to be fetched",
account_id, mailbox.name, len
account.id, mailbox.name, len
);
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
uid_vec.sort();
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
let too_many = len as u32 > 10 * BATCH_SIZE;
let too_many = len as u32 > 5 * account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
if too_many {
AccountRunningState::set_initial_current_syncing_folder(
account_id,
account.id,
mailbox.name.clone(),
uid_batches.len() as u32,
)
@@ -118,13 +139,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(())
@@ -142,7 +163,7 @@ impl ImapExecutor {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
let total = session
.examine(encoded_mailbox_name)
.await
@@ -211,7 +232,7 @@ impl ImapExecutor {
uid_set: &str,
encoded_mailbox_name: &str,
) -> BichonResult<()> {
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
session
.examine(encoded_mailbox_name)
.await
@@ -238,4 +259,41 @@ impl ImapExecutor {
}
Ok(())
}
async fn get_connection(
&self,
) -> BichonResult<bb8::PooledConnection<'_, ImapConnectionManager>> {
match self.pool.get().await {
Ok(connection) => Ok(connection),
Err(e) => match e {
RunError::User(e) => Err(e),
RunError::TimedOut => {
let state = self.pool.state();
tracing::warn!(
"{}: connections={}, idle={}, \
get_started={}, get_direct={}, get_waited={}, get_timed_out={}, \
wait_time_ms={}, created={}, closed_broken={}, closed_invalid={}, \
closed_lifetime={}, closed_idle={}",
self.account_id,
state.connections,
state.idle_connections,
state.statistics.get_started,
state.statistics.get_direct,
state.statistics.get_waited,
state.statistics.get_timed_out,
state.statistics.get_wait_time.as_millis(),
state.statistics.connections_created,
state.statistics.connections_closed_broken,
state.statistics.connections_closed_invalid,
state.statistics.connections_closed_max_lifetime,
state.statistics.connections_closed_idle_timeout,
);
return Err(raise_error!(
"Timed out while attempting to acquire a connection from the pool".into(),
ErrorCode::ConnectionPoolTimeout
));
}
},
}
}
}
+12 -2
View File
@@ -78,7 +78,13 @@ impl ImapConnectionManager {
})?;
let password = decrypt!(&password)?;
client.login(&username, &password).await
client.login(&username, &password).await.map_err(|e| {
error!(
"IMAP password auth failed for username '{}': {}",
username, e
);
e
})
}
AuthType::OAuth2 => {
let record = OAuth2AccessToken::get(self.account_id).await?;
@@ -90,8 +96,12 @@ impl ImapConnectionManager {
)
})?;
client
.authenticate(OAuth2::new(username, access_token))
.authenticate(OAuth2::new(username.clone(), access_token))
.await
.map_err(|e| {
error!("IMAP OAuth2 auth failed for username '{}': {}", username, e);
e
})
}
}
}
+1 -2
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::error::{BichonError, BichonResult};
use crate::modules::imap::{manager::ImapConnectionManager, session::SessionStream};
@@ -49,7 +48,7 @@ pub async fn build_imap_pool(account_id: u64) -> BichonResult<Pool<ImapConnectio
let manager = ImapConnectionManager::new(account_id);
let pool = Pool::builder()
.connection_timeout(Duration::from_secs(30))
.idle_timeout(Duration::from_secs(120))
//.idle_timeout(Duration::from_secs(120))
.retry_connection(true)
.max_size(10)
.test_on_check_out(true)
+73 -2
View File
@@ -16,11 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use mail_parser::MessageParser;
use mail_parser::{parsers::MessageStream, HeaderName, MessageParser};
use crate::{
base64_encode_url_safe,
modules::{account::entity::Encryption, imap::client::Client},
modules::{
account::entity::Encryption, envelope::utils::normalize_subject, imap::client::Client,
},
};
#[tokio::test]
@@ -48,3 +50,72 @@ async fn test1() {
println!("{}", part.is_multipart());
}
}
#[tokio::test]
async fn test2() {
const MESSAGE: &str = r#"From: Art Vandelay <art@vandelay.com> (Vandelay Industries)
To: "Colleagues": "James Smythe" <james@vandelay.com>; Friends:
jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?= <john@example.com>;
Date: Sat, 20 Nov 2021 14:22:01 -0800
Subject: =?utf-8?B?SnVzdCAxNSBkYXlzIGxlZnQgdG8gdmlzaXQgTkFSTklBISDinYTvuI/wn462?=
Content-Type: multipart/mixed; boundary="festivus";
--festivus
Content-Type: text/html; charset="us-ascii"
Content-Transfer-Encoding: base64
PGh0bWw+PHA+SSB3YXMgdGhpbmtpbmcgYWJvdXQgcXVpdHRpbmcgdGhlICZsZHF1bztle
HBvcnRpbmcmcmRxdW87IHRvIGZvY3VzIGp1c3Qgb24gdGhlICZsZHF1bztpbXBvcnRpbm
cmcmRxdW87LDwvcD48cD5idXQgdGhlbiBJIHRob3VnaHQsIHdoeSBub3QgZG8gYm90aD8
gJiN4MjYzQTs8L3A+PC9odG1sPg==
--festivus
Content-Type: message/rfc822
From: "Cosmo Kramer" <kramer@kramerica.com>
Subject: Exporting my book about coffee tables
Content-Type: multipart/mixed; boundary="giddyup";
--giddyup
Content-Type: text/plain; charset="utf-16"
Content-Transfer-Encoding: quoted-printable
=FF=FE=0C!5=D8"=DD5=D8)=DD5=D8-=DD =005=D8*=DD5=D8"=DD =005=D8"=
=DD5=D85=DD5=D8-=DD5=D8,=DD5=D8/=DD5=D81=DD =005=D8*=DD5=D86=DD =
=005=D8=1F=DD5=D8,=DD5=D8,=DD5=D8(=DD =005=D8-=DD5=D8)=DD5=D8"=
=DD5=D8=1E=DD5=D80=DD5=D8"=DD!=00
--giddyup
Content-Type: image/gif; name*1="about "; name*0="Book ";
name*2*=utf-8''%e2%98%95 tables.gif
Content-Transfer-Encoding: Base64
Content-Disposition: attachment
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
--giddyup--
--festivus--
"#;
let message = MessageParser::default().parse(MESSAGE).unwrap();
let raw_subject = message.header_raw("Subject").unwrap().as_bytes();
let data = MessageStream::new(raw_subject)
.parse_unstructured()
.unwrap_text()
.to_string();
println!("{}", data);
// RFC2047 support for encoded text in message readers
println!("{}", message.subject().unwrap());
}
#[tokio::test]
async fn test44() {
let path = r"C:\Users\polly\Downloads\test222.eml";
let input = std::fs::read(path).unwrap();
let message = MessageParser::default().parse(&input).unwrap();
let subject = message.subject().unwrap();
println!("Subject: {}", subject);
if subject.contains('\u{FFFD}') {
let subject = normalize_subject(message.header_raw(HeaderName::Subject));
println!("Subject: {}", subject);
}
}
+13 -1
View File
@@ -16,7 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::error::code::ErrorCode;
use crate::modules::utils::create_hash;
use crate::modules::{error::BichonResult, indexer::schema::SchemaTools};
@@ -31,7 +32,9 @@ pub struct Envelope {
pub id: u64,
pub message_id: String,
pub account_id: u64,
pub account_email: Option<String>,
pub mailbox_id: u64,
pub mailbox_name: Option<String>,
pub uid: u32,
pub subject: String,
pub text: String,
@@ -169,11 +172,20 @@ impl Envelope {
})
.flatten()
.collect();
let account_email = AccountModel::find(account_id).await?.map(|a| a.email);
let mailboxes = MailBox::list_all(account_id).await?;
let mailbox_name = mailboxes
.iter()
.find(|m| m.id == mailbox_id)
.map(|m| m.name.clone());
let envelope = Envelope {
id,
account_id,
account_email,
mailbox_id,
mailbox_name,
message_id: extract_string_field(doc, fields.f_message_id)?,
uid: extract_u64_field(doc, fields.f_uid)? as u32,
subject: extract_string_field(doc, fields.f_subject)?,
+169 -21
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
collections::{HashMap, HashSet},
ops::Bound,
@@ -35,8 +34,8 @@ use crate::{
indexer::{
envelope::Envelope,
fields::{
F_ACCOUNT_ID, F_FROM, F_HAS_ATTACHMENT, F_INTERNAL_DATE, F_MAILBOX_ID, F_SIZE,
F_TAGS, F_THREAD_ID, F_UID,
F_ACCOUNT_ID, F_DATE, F_FROM, F_HAS_ATTACHMENT, F_MAILBOX_ID, F_SIZE, F_TAGS,
F_THREAD_ID, F_UID,
},
schema::SchemaTools,
},
@@ -58,7 +57,7 @@ use tantivy::{
AggregationCollector, Key,
},
collector::{Count, FacetCollector, TopDocs},
query::{AllQuery, BooleanQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
schema::{Facet, IndexRecordOption, Value},
store::{Compressor, ZstdCompressor},
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
@@ -194,9 +193,29 @@ impl EnvelopeIndexManager {
}
}
pub fn total_emails(&self) -> BichonResult<u64> {
pub fn total_emails(&self, accounts: &Option<HashSet<u64>>) -> BichonResult<u64> {
let searcher = self.create_searcher()?;
Ok(searcher.num_docs())
match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
let query = Box::new(BooleanQuery::new(subqueries)) as Box<dyn Query>;
let count = searcher
.search(&query, &Count)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(count as u64)
}
Some(_) => Ok(0),
None => Ok(searcher.num_docs()),
}
}
fn account_query(&self, account_id: u64) -> Box<TermQuery> {
@@ -223,12 +242,36 @@ impl EnvelopeIndexManager {
fn filter_query(
&self,
accounts: Option<HashSet<u64>>,
filter: SearchFilter,
parser: QueryParser,
) -> BichonResult<Box<dyn Query>> {
let f = SchemaTools::envelope_fields();
let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
if let Some(authorized_ids) = accounts {
if authorized_ids.is_empty() {
let term = Term::from_field_u64(f.f_account_id, u64::MAX);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
} else {
let mut account_must_queries = Vec::new();
for id in authorized_ids {
let term = Term::from_field_u64(f.f_account_id, id);
account_must_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
subqueries.push((
Occur::Must,
Box::new(BooleanQuery::new(account_must_queries)),
));
}
}
if let Some(ref text) = filter.text {
let query = parser
.parse_query(text)
@@ -292,13 +335,13 @@ impl EnvelopeIndexManager {
}
let start_bound = if let Some(from) = filter.since {
Bound::Included(Term::from_field_i64(f.f_internal_date, from))
Bound::Included(Term::from_field_i64(f.f_date, from))
} else {
Bound::Unbounded
};
let end_bound = if let Some(to) = filter.before {
Bound::Included(Term::from_field_i64(f.f_internal_date, to))
Bound::Included(Term::from_field_i64(f.f_date, to))
} else {
Bound::Unbounded
};
@@ -426,14 +469,16 @@ impl EnvelopeIndexManager {
}
fn collect_facets_recursive(
query: &dyn Query,
searcher: &Searcher,
parent_facet: &str,
all_facets: &mut Vec<TagCount>,
) -> BichonResult<()> {
let mut facet_collector = FacetCollector::for_field(F_TAGS);
facet_collector.add_facet(parent_facet);
let facet_counts = searcher
.search(&AllQuery, &facet_collector)
.search(query, &facet_collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for (facet, count) in facet_counts.get(parent_facet) {
@@ -441,16 +486,37 @@ impl EnvelopeIndexManager {
tag: facet.to_string(),
count,
});
Self::collect_facets_recursive(searcher, &facet.to_string(), all_facets)?;
Self::collect_facets_recursive(query, searcher, &facet.to_string(), all_facets)?;
}
Ok(())
}
pub async fn get_all_tags(&self) -> BichonResult<Vec<TagCount>> {
pub async fn get_all_tags(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<Vec<TagCount>> {
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mut all_facets = Vec::new();
Self::collect_facets_recursive(&searcher, "/", &mut all_facets)?;
Self::collect_facets_recursive(&query, &searcher, "/", &mut all_facets)?;
Ok(all_facets)
}
@@ -550,6 +616,7 @@ impl EnvelopeIndexManager {
pub async fn search(
&self,
accounts: Option<HashSet<u64>>,
filter: SearchFilter,
page: u64,
page_size: u64,
@@ -557,7 +624,7 @@ impl EnvelopeIndexManager {
) -> BichonResult<DataPage<Envelope>> {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let query = self.filter_query(filter, self.query_parser.clone())?;
let query = self.filter_query(accounts, filter, self.query_parser.clone())?;
let searcher = self.create_searcher()?;
let total = searcher
.search(&query, &Count)
@@ -591,7 +658,7 @@ impl EnvelopeIndexManager {
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -654,7 +721,7 @@ impl EnvelopeIndexManager {
query.as_ref(),
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -719,7 +786,7 @@ impl EnvelopeIndexManager {
query.as_ref(),
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -741,15 +808,77 @@ impl EnvelopeIndexManager {
})
}
pub async fn top_10_largest_emails(&self) -> BichonResult<Vec<LargestEmail>> {
pub async fn get_envelope_by_id(
&self,
account_id: u64,
message_id: u64,
) -> BichonResult<Option<Envelope>> {
let searcher = self.create_searcher()?;
let f = SchemaTools::envelope_fields();
let query = BooleanQuery::new(vec![
(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_u64(f.f_account_id, account_id),
IndexRecordOption::Basic,
)),
),
(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_u64(f.f_id, message_id),
IndexRecordOption::Basic,
)),
),
]);
let docs: Vec<(f32, DocAddress)> = searcher
.search(&query, &TopDocs::with_limit(1))
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if let Some((_, doc_address)) = docs.first() {
let doc: TantivyDocument = searcher
.doc_async(*doc_address)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let envelope = Envelope::from_tantivy_doc(&doc).await?;
Ok(Some(envelope))
} else {
Ok(None)
}
}
pub async fn top_10_largest_emails(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<Vec<LargestEmail>> {
self.reader
.reload()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mailbox_docs: Vec<(u64, DocAddress)> = searcher
.search(
&AllQuery,
&query,
&TopDocs::with_limit(10).order_by_fast_field(F_SIZE, Order::Desc),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -905,7 +1034,10 @@ impl EnvelopeIndexManager {
Ok(self.reader.searcher())
}
pub async fn get_dashboard_stats(&self) -> BichonResult<DashboardStats> {
pub async fn get_dashboard_stats(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<DashboardStats> {
let searcher = self.create_searcher()?;
let now_ms = utc_now!();
let week_ago_ms = (Utc::now() - Duration::from_secs(60 * 60 * 24 * 30)).timestamp_millis();
@@ -916,7 +1048,7 @@ impl EnvelopeIndexManager {
},
"recent_30d_histogram": {
"histogram": {
"field": F_INTERNAL_DATE,
"field": F_DATE,
"interval": 86400000,
"hard_bounds": {
"min": week_ago_ms,
@@ -944,7 +1076,23 @@ impl EnvelopeIndexManager {
}))
.unwrap();
let query = AllQuery;
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let agg_collector = AggregationCollector::from_aggs(aggregations, Default::default());
let agg_results = searcher
.search(&query, &agg_collector)
+7 -1
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
use crate::modules::context::executors::MAIL_CONTEXT;
@@ -64,8 +63,15 @@ pub async fn convert_names_to_mailboxes(
for name in names.into_iter() {
// Convert the name into a MailBox structure
let mailbox_name = name.name().to_string();
let mut mailbox: MailBox = name.into();
tracing::debug!(
raw = &mailbox_name,
decoded = &mailbox.name,
"mailbox name comparison"
);
if contains_no_select(&mailbox.attributes) {
continue;
}
+104
View File
@@ -0,0 +1,104 @@
use crate::{
encode_mailbox_name,
modules::{
account::migration::{AccountModel, AccountType},
context::executors::MAIL_CONTEXT,
error::{code::ErrorCode, BichonResult},
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
},
raise_error,
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
const MAX_RESTORE_COUNT: usize = 100;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RestoreMessagesRequest {
/// Message IDs to restore (max 100)
pub message_ids: Vec<u64>,
}
pub async fn restore_emails(account_id: u64, message_ids: Vec<u64>) -> BichonResult<()> {
if message_ids.len() > MAX_RESTORE_COUNT {
return Err(raise_error!(
format!(
"Too many messages to restore: {} (max {})",
message_ids.len(),
MAX_RESTORE_COUNT
),
ErrorCode::InvalidParameter
));
}
let account = AccountModel::check_account_exists(account_id).await?;
if !matches!(account.account_type, AccountType::IMAP) {
return Err(raise_error!(
"Account type is not IMAP".into(),
ErrorCode::Incompatible
));
}
let executor = MAIL_CONTEXT.imap(account.id).await?;
let mut failed = Vec::new();
for message_id in message_ids {
let result: BichonResult<()> = async {
let envelope = ENVELOPE_INDEX_MANAGER
.get_envelope_by_id(account_id, message_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Envelope not found: account_id={} message_id={}",
account_id, message_id
),
ErrorCode::ResourceNotFound
)
})?;
let eml = EML_INDEX_MANAGER
.get(account_id, message_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Email record not found: account_id={} id={}",
account_id, message_id
),
ErrorCode::ResourceNotFound
)
})?;
if let Some(mailbox_name) = envelope.mailbox_name {
executor
.append(encode_mailbox_name!(&mailbox_name), None, None, &eml)
.await?;
}
Ok(())
}
.await;
if let Err(err) = result {
failed.push(message_id);
tracing::warn!(
account_id = account_id,
message_id = message_id,
error = ?err,
"Failed to restore email"
);
}
}
if !failed.is_empty() {
tracing::info!(
account_id = account_id,
failed_count = failed.len(),
failed_message_ids = ?failed,
"Restore emails finished with partial failures"
);
}
Ok(())
}
+1 -1
View File
@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod append;
pub mod content;
pub mod delete;
pub mod list;
+12 -2
View File
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashSet;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
@@ -72,9 +73,18 @@ impl SearchRequest {
}
}
pub async fn search_messages_impl(request: SearchRequest) -> BichonResult<DataPage<Envelope>> {
pub async fn search_messages_impl(
accounts: Option<HashSet<u64>>,
request: SearchRequest,
) -> BichonResult<DataPage<Envelope>> {
request.validate()?;
ENVELOPE_INDEX_MANAGER
.search(request.filter, request.page, request.page_size, true)
.search(
accounts,
request.filter,
request.page,
request.page_size,
true,
)
.await
}
+1
View File
@@ -36,5 +36,6 @@ pub mod rest;
pub mod settings;
pub mod tasks;
pub mod token;
pub mod users;
pub mod utils;
pub mod version;
+21 -1
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
encrypt, id,
modules::{
@@ -97,6 +96,27 @@ impl OAuth2 {
})
}
pub fn scrub_sensitive_fields(&mut self) {
let mask = "********";
let notice =
" [REDACTED: You do not have permission to view sensitive configuration details]";
let original_desc = self
.description
.clone()
.unwrap_or_else(|| "OAuth2 Config".to_string());
self.description = Some(format!("{}{}", original_desc, notice));
self.client_id = mask.to_string();
self.client_secret = mask.to_string();
self.auth_url = mask.to_string();
self.token_url = mask.to_string();
self.redirect_uri = mask.to_string();
self.scopes = None;
self.extra_params = None;
}
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await?;
Ok(())
+28 -90
View File
@@ -16,16 +16,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::token::root::set_root_password;
use crate::modules::{
token::payload::AccessTokenCreateRequest,
token::{root::reset_root_token, AccessToken},
};
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::permissions::Permission;
use crate::modules::{token::payload::AccessTokenCreateRequest, token::AccessTokenModel};
use poem_openapi::payload::PlainText;
use poem_openapi::{param::Path, payload::Json, OpenApi};
@@ -33,9 +29,6 @@ pub struct AccessTokenApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AccessToken")]
impl AccessTokenApi {
/// Lists all access tokens in the system.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list",
method = "get",
@@ -44,31 +37,15 @@ impl AccessTokenApi {
async fn list_access_tokens(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_all().await?))
) -> ApiResult<Json<Vec<AccessTokenResp>>> {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
Ok(Json(AccessTokenModel::list_all_api_tokens().await?))
}
/// Lists access tokens for a specific account.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list/:account_id",
method = "get",
operation_id = "list_account_access_tokens"
)]
async fn list_account_access_tokens(
&self,
/// The ID of the account whose tokens are to be retrieved.
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_account_tokens(account_id.0).await?))
}
/// Deletes a specific access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "delete",
@@ -80,13 +57,18 @@ impl AccessTokenApi {
token: Path<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::delete(token.0.trim()).await?)
let token = token.0.trim();
let token = AccessTokenModel::get_token(token).await?;
if context.user.id != token.user_id {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
}
Ok(AccessTokenModel::delete(&token.token).await?)
}
/// Creates a new access token.
///
/// Requires root privileges.
/// Creates a new api token.
#[oai(
path = "/access-token",
method = "post",
@@ -98,59 +80,15 @@ impl AccessTokenApi {
/// The request payload
payload: Json<AccessTokenCreateRequest>,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(AccessToken::create(payload.0).await?))
}
let current_user_id = context.user.id;
let target_user_id = payload.0.user_id.unwrap_or(current_user_id);
if target_user_id != current_user_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
/// Updates an existing access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "post",
operation_id = "update_access_token"
)]
async fn update_access_token(
&self,
context: ClientContext,
/// The access token to be updated.
token: Path<String>,
/// The request payload.
payload: Json<AccessTokenUpdateRequest>,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::update(token.0.trim(), payload.0).await?)
}
/// Regenerates the root access token.
///
/// Requires root privileges.
#[oai(
path = "/reset-root-token",
method = "post",
operation_id = "regenerate_root_token"
)]
async fn regenerate_root_token(&self, context: ClientContext) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(reset_root_token().await?))
}
/// Reset the Root user's password.
///
/// Only callable by an already authenticated Root user.
/// This endpoint updates the Root password to `password_str`
/// and regenerates the `root_token`, invalidating any previous token.
#[oai(
path = "/reset-root-password",
method = "post",
operation_id = "reset_root_password"
)]
async fn reset_root_password(
&self,
password_str: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(set_root_password(password_str.0.trim()).await?)
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0).await?;
Ok(PlainText(token_string))
}
}
+91 -47
View File
@@ -16,23 +16,25 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use std::collections::{HashMap, HashSet};
use crate::modules::account::grant::BatchAccountRoleRequest;
use crate::modules::account::migration::AccountModel;
use crate::modules::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use crate::modules::account::state::AccountRunningState;
use crate::modules::account::view::AccountResp;
use crate::modules::common::auth::ClientContext;
use crate::modules::common::paginated::paginate_vec;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::token::{AccessToken, AccountInfo};
use crate::modules::users::permissions::Permission;
use crate::modules::users::UserModel;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -53,7 +55,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
Ok(Json(AccountModel::get(account_id).await?))
}
@@ -70,7 +74,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::delete(account_id).await?)
}
@@ -82,14 +88,10 @@ impl AccountApi {
payload: Json<AccountCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account = AccountModel::create_account(payload.0).await?;
if let Some(access_token) = &context.access_token {
let account_info = AccountInfo {
id: account.id,
email: account.email.clone(),
};
AccessToken::grant_account_access(&access_token.token, account_info).await?;
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let account = AccountModel::create_account(context.user.id, payload.0).await?;
Ok(Json(account))
}
@@ -108,7 +110,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::update(account_id, payload.0, true).await?)
}
@@ -123,35 +127,61 @@ impl AccountApi {
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<AccountModel>>> {
let accessible_accounts = context.accessible_accounts()?;
) -> ApiResult<Json<DataPage<AccountResp>>> {
let is_admin = context.user.is_admin().await;
let sort_desc = desc.0.unwrap_or(true);
if accessible_accounts.is_none() {
return Ok(Json(
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?,
));
}
let all_accounts = AccountModel::list_all().await?;
let allowed_ids: BTreeSet<u64> =
accessible_accounts.unwrap().iter().map(|a| a.id).collect();
let mut filtered_accounts: Vec<AccountModel> = all_accounts
let user_map: HashMap<u64, UserModel> = UserModel::list_all()
.await?
.into_iter()
.filter(|acct| allowed_ids.contains(&acct.id))
.map(|u| (u.id, u))
.collect();
let page_data: DataPage<AccountModel> = if is_admin {
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?
} else {
let authorized_ids: HashSet<u64> =
context.user.account_access_map.keys().cloned().collect();
if authorized_ids.is_empty() {
return Ok(Json(DataPage {
current_page: page.0,
page_size: page_size.0,
total_items: 0,
items: vec![],
total_pages: Some(0),
}));
}
let mut accounts: Vec<AccountModel> = AccountModel::list_all()
.await?
.into_iter()
.filter(|acct| authorized_ids.contains(&acct.id))
.collect();
accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
paginate_vec(&accounts, page.0, page_size.0).map(DataPage::from)?
};
let items = page_data
.items
.into_iter()
.map(|account| AccountResp::from_model(account, &user_map))
.collect();
let sort_desc = desc.0.unwrap_or(true);
filtered_accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
let page_data =
paginate_vec(&filtered_accounts, page.0, page_size.0).map(DataPage::from)?;
Ok(Json(page_data))
Ok(Json(DataPage {
current_page: page_data.current_page,
page_size: page_data.page_size,
total_items: page_data.total_items,
total_pages: page_data.total_pages,
items,
}))
}
/// Get the running state of an account
@@ -168,7 +198,9 @@ impl AccountApi {
) -> ApiResult<Json<AccountRunningState>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
raise_error!(
"account running state is not found".into(),
@@ -191,13 +223,25 @@ impl AccountApi {
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let accessible_accounts = context.accessible_accounts()?;
let is_admin = context.user.is_admin().await;
let minimal_list = AccountModel::minimal_list().await?;
let result = match accessible_accounts {
Some(set) => filter_accessible_accounts(&minimal_list, set),
None => minimal_list,
};
if is_admin {
return Ok(Json(minimal_list));
}
let authorized_ids: Vec<u64> = context.user.account_access_map.keys().cloned().collect();
let result = filter_accessible_accounts(&minimal_list, &authorized_ids);
Ok(Json(result))
}
#[oai(path = "/accounts/access/assignments", method = "post")]
async fn batch_assign_account_role(
&self,
req: Json<BatchAccountRoleRequest>,
context: ClientContext,
) -> ApiResult<()> {
req.validate_existence().await?;
req.0.do_assign(&context).await?;
Ok(())
}
}
+9 -3
View File
@@ -16,14 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::autoconfig::entity::MailServerConfig;
use crate::modules::autoconfig::load::resolve_autoconfig;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -40,8 +41,13 @@ impl AutoConfigApi {
async fn autoconfig(
&self,
/// The email address to lookup configuration for
email_address: Path<String>
email_address: Path<String>,
context: ClientContext,
) -> ApiResult<Json<MailServerConfig>> {
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let result = resolve_autoconfig(email_address.0.trim())
.await?
.ok_or_else(|| {
+4 -1
View File
@@ -21,6 +21,7 @@ use crate::modules::import::BatchEmlResult;
use crate::modules::import::{BatchEmlRequest, ImportEmls};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -43,7 +44,9 @@ impl ImportApi {
payload: Json<BatchEmlRequest>,
context: ClientContext,
) -> ApiResult<Json<BatchEmlResult>> {
context.require_root()?;
context
.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
.await?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}
+5 -4
View File
@@ -16,14 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::common::auth::ClientContext;
use crate::modules::mailbox::list::get_account_mailboxes;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use poem::web::Path;
use poem_openapi::param::Query;
use crate::modules::users::permissions::Permission;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -53,7 +52,9 @@ impl MailBoxApi {
context: ClientContext,
) -> ApiResult<Json<Vec<MailBox>>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}
+137 -31
View File
@@ -21,6 +21,8 @@ use crate::modules::common::auth::ClientContext;
use crate::modules::indexer::envelope::Envelope;
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
use crate::modules::message::append::restore_emails;
use crate::modules::message::append::RestoreMessagesRequest;
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
use crate::modules::message::delete::delete_messages_impl;
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
@@ -31,13 +33,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::web::Path;
use poem::Body;
use poem_openapi::param::Query;
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;
@@ -58,12 +61,14 @@ 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?)
}
/// Lists messages in a specified mailbox for the given account.
/// Lists messages in a mailbox. Requires `mailbox_id`, `page`, and `page_size` query parameters.
#[oai(
path = "/list-messages/:account_id",
method = "get",
@@ -71,7 +76,9 @@ impl MessageApi {
)]
async fn list_messages(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the mailbox to list messages from.
mailbox_id: Query<u64>,
page: Query<u64>,
page_size: Query<u64>,
@@ -79,13 +86,16 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
list_messages_impl(account_id, mailbox_id, page.0, page_size.0).await?,
))
}
/// Lists messages in a specified mailbox for the given account.
/// Searches messages across all mailboxes using various filter criteria.
/// The search filters are provided in the request body.
#[oai(
path = "/search-messages",
method = "post",
@@ -96,11 +106,18 @@ impl MessageApi {
payload: Json<SearchRequest>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
context.require_root()?;
Ok(Json(search_messages_impl(payload.0).await?))
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
}
/// Get thread's envelopes in a specified mailbox for the given account.
/// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters.
#[oai(
path = "/get-thread-messages/:account_id",
method = "get",
@@ -120,73 +137,141 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let thread_id = thread_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
))
}
/// Fetches the content of a specific email for the given account.
/// Fetches the content of a specific email.
#[oai(
path = "/message-content/:account_id",
path = "/message-content/:account_id/:message_id",
method = "get",
operation_id = "fetch_message_content"
)]
async fn fetch_message_content(
&self,
/// The ID of the account.
account_id: Path<u64>,
id: Query<u64>,
/// The ID of the message to fetch.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(Json(retrieve_email_content(account_id, id.0).await?))
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
retrieve_email_content(account_id, message_id.0).await?,
))
}
/// Fetches the full content of a specific email for the given account.
/// Retrieves the envelope (metadata) of a specific message.
#[oai(
path = "/download-message/:account_id",
path = "/envelope/:account_id/:message_id",
method = "get",
operation_id = "get_envelope"
)]
async fn get_envelope(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Envelope>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let envelope = ENVELOPE_INDEX_MANAGER
.get_envelope_by_id(account_id, message_id.0)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Envelope not found: account_id={} message_id={}",
account_id, message_id.0
),
ErrorCode::ResourceNotFound
)
})?;
Ok(Json(envelope))
}
/// Downloads the raw EML file of a specific email.
#[oai(
path = "/download-message/:account_id/:message_id",
method = "get",
operation_id = "download_message"
)]
async fn download_message(
&self,
/// The ID of the account.
account_id: Path<u64>,
id: Query<u64>,
/// The ID of the message to download.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
let id = id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, id).await?;
context
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
.await?;
let message_id = message_id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id).await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
.filename(format!("{id}.eml"));
.filename(format!("{message_id}.eml"));
Ok(attachment)
}
/// Downloads a specific attachment by filename.
#[oai(
path = "/download-attachment/:account_id",
path = "/restore-messages/:account_id",
method = "post",
operation_id = "restore_messages"
)]
async fn restore_messages(
&self,
account_id: Path<u64>,
/// Message IDs to restore.
payload: Json<RestoreMessagesRequest>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)
.await?;
Ok(restore_emails(account_id, payload.0.message_ids).await?)
}
/// Downloads a specific attachment from an email. Requires `name` query parameter.
#[oai(
path = "/download-attachment/:account_id/:message_id",
method = "get",
operation_id = "download_attachment"
)]
async fn download_attachment(
&self,
/// The ID of the account.
account_id: Path<u64>,
id: Query<u64>,
/// The ID of the message containing the attachment.
message_id: Path<u64>,
/// The filename of the attachment to download.
name: Query<String>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
let email_id = 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, email_id, name)
.get_attachment(account_id, message_id.0, name)
.await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
@@ -196,8 +281,18 @@ impl MessageApi {
}
/// Returns all facets in the index along with their document counts.
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
async fn get_all_tags(&self) -> ApiResult<Json<Vec<TagCount>>> {
Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?))
async fn get_all_tags(&self, context: ClientContext) -> ApiResult<Json<Vec<TagCount>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_INDEX_MANAGER.get_all_tags(authorized_ids).await?,
))
}
/// Adds or removes facet tags for multiple emails across accounts.
@@ -206,12 +301,23 @@ impl MessageApi {
method = "post",
operation_id = "update_envelope_tags"
)]
async fn update_envelope_tags(&self, req: Json<UpdateTagsRequest>) -> ApiResult<()> {
async fn update_envelope_tags(
&self,
req: Json<UpdateTagsRequest>,
context: ClientContext,
) -> ApiResult<()> {
let req = req.0;
for tag in &req.tags {
Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
}
for account_id in req.updates.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
.await?;
}
ENVELOPE_INDEX_MANAGER
.update_envelope_tags(req.updates, req.tags)
.await?;
+8 -1
View File
@@ -25,7 +25,10 @@ use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use system::SystemApi;
use crate::{bichon_version, modules::rest::api::import::ImportApi};
use crate::{
bichon_version,
modules::rest::api::{import::ImportApi, users::UsersApi},
};
pub mod access_token;
pub mod account;
@@ -35,6 +38,7 @@ pub mod mailbox;
pub mod message;
pub mod oauth2;
pub mod system;
pub mod users;
#[derive(Tags)]
pub enum ApiTags {
@@ -46,6 +50,7 @@ pub enum ApiTags {
Message,
System,
Import,
Users,
}
type RustMailOpenApi = (
@@ -57,6 +62,7 @@ type RustMailOpenApi = (
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
@@ -70,6 +76,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
),
"BichonApi",
bichon_version!(),
+60 -18
View File
@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
@@ -25,9 +25,9 @@ use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Json, PlainText};
use poem_openapi::OpenApi;
@@ -50,14 +50,26 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2>> {
context.require_root()?;
let id = id.0;
Ok(Json(OAuth2::get(id).await?.ok_or_else(|| {
let mut oauth2 = OAuth2::get(id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
)
})?))
})?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(oauth2));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
oauth2.scrub_sensitive_fields();
Ok(Json(oauth2))
}
/// Deletes an OAuth2 configuration by name.
@@ -75,7 +87,9 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::delete(id.0).await?)
}
@@ -94,7 +108,9 @@ impl OAuth2Api {
request: Json<OAuth2CreateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = OAuth2::new(request.0)?;
Ok(entity.save().await?)
}
@@ -116,7 +132,9 @@ impl OAuth2Api {
payload: Json<OAuth2UpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::update(id.0, payload.0).await?)
}
@@ -139,10 +157,23 @@ impl OAuth2Api {
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
context.require_root()?;
Ok(Json(
OAuth2::paginate_list(page.0, page_size.0, desc.0).await?,
))
let mut list = OAuth2::paginate_list(page.0, page_size.0, desc.0).await?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(list));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
for item in &mut list.items {
item.scrub_sensitive_fields();
}
Ok(Json(list))
}
/// Generates an OAuth2 authorization URL for a specific account.
@@ -160,8 +191,14 @@ impl OAuth2Api {
request: Json<AuthorizeUrlRequest>,
context: ClientContext,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
let request = request.0;
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(Some(request.account_id), Permission::ACCOUNT_MANAGE),
])
.await?;
let flow = OAuth2Flow::new(request.oauth2_id);
Ok(PlainText(flow.authorize_url(request.account_id).await?))
}
@@ -181,7 +218,9 @@ impl OAuth2Api {
context: ClientContext,
) -> ApiResult<Json<OAuth2AccessToken>> {
let account = account_id.0;
context.require_account_access(account)?;
context
.require_permission(Some(account), Permission::ACCOUNT_MANAGE)
.await?;
Ok(Json(OAuth2AccessToken::get(account).await?.ok_or_else(
|| {
raise_error!(
@@ -219,10 +258,13 @@ impl OAuth2Api {
request: Json<ExternalOAuth2Request>,
context: ClientContext,
) -> ApiResult<()> {
let account = account_id.0;
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
// Check account access permissions
context.require_account_access(account)?;
OAuth2AccessToken::upsert_external_oauth_token(account, request.0).await?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0).await?;
Ok(())
}
}
+46 -11
View File
@@ -16,13 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::dashboard::DashboardStats;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::SystemConfigurations;
use crate::modules::users::permissions::Permission;
use crate::modules::version::{fetch_notifications, Notifications};
use crate::raise_error;
use poem_openapi::param::Path;
@@ -60,14 +62,20 @@ impl SystemApi {
path = "/dashboard-stats",
operation_id = "get_dashboard_stats"
)]
async fn get_dashboard_stats(&self) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get().await?;
async fn get_dashboard_stats(&self, context: ClientContext) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get(context).await?;
Ok(Json(stats))
}
/// Get the full list of SOCKS5 proxy configurations.
#[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")]
async fn list_proxy(&self) -> ApiResult<Json<Vec<Proxy>>> {
async fn list_proxy(&self, context: ClientContext) -> ApiResult<Json<Vec<Proxy>>> {
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(None, Permission::ROOT),
])
.await?;
let proxies = Proxy::list_all()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -78,30 +86,36 @@ impl SystemApi {
#[oai(path = "/proxy/:id", method = "delete", operation_id = "remove_proxy")]
async fn remove_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
/// The ID of the proxy configuration to delete.
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::delete(id.0).await?)
}
/// Retrieve a specific proxy configuration by ID
/// Retrieve a specific proxy configuration by ID. Requires root permission.
#[oai(path = "/proxy/:id", method = "get", operation_id = "get_proxy")]
async fn get_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
/// The ID of the proxy configuration to retrieve.
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Proxy>> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Json(Proxy::get(id.0).await?))
}
/// Create a new proxy configuration. Requires root permission.
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
async fn create_proxy(&self, url: PlainText<String>, context: ClientContext) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = Proxy::new(url.0);
Ok(entity.save().await?)
}
@@ -114,7 +128,28 @@ impl SystemApi {
url: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::update(id.0, url.0).await?)
}
/// Get system configurations.
///
/// Returns a read-only snapshot of the server configuration
/// resolved at startup. Sensitive values are not exposed.
#[oai(
method = "get",
path = "/system-configurations",
operation_id = "get_system_configurations"
)]
async fn get_system_configurations(
&self,
context: ClientContext,
) -> ApiResult<Json<SystemConfigurations>> {
context
.require_permission(None, Permission::ROOT)
.await?;
let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS);
Ok(Json(config))
}
}
+217
View File
@@ -0,0 +1,217 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeMap;
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::minimal::MinimalUser;
use crate::modules::users::payload::{
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
};
use crate::modules::users::permissions::Permission;
use crate::modules::users::role::UserRole;
use crate::modules::users::view::UserView;
use crate::modules::users::UserModel;
use poem::web::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct UsersApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Users")]
impl UsersApi {
#[oai(path = "/list-roles", method = "get", operation_id = "list_roles")]
async fn list_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(UserRole::list_all().await?))
}
#[oai(path = "/roles/:id", method = "delete", operation_id = "remove_role")]
async fn remove_role(
&self,
/// The Role ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::delete(id).await?)
}
/// Create a new account
#[oai(path = "/roles", method = "post", operation_id = "create_role")]
async fn create_role(
&self,
/// Role creation request payload
payload: Json<RoleCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserRole>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let role = UserRole::create(payload.0).await?;
Ok(Json(role))
}
/// Update an existing account
#[oai(path = "/roles/:id", method = "post", operation_id = "update_role")]
async fn update_role(
&self,
/// The Role ID to update
id: Path<u64>,
/// Role update request payload
payload: Json<RoleUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::update(id, payload.0).await?)
}
#[oai(path = "/list-users", method = "get", operation_id = "list_users")]
async fn list_users(&self, context: ClientContext) -> ApiResult<Json<Vec<UserView>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
let users = UserModel::list_all().await?;
let users = users
.into_iter()
.map(|u| u.to_view(&role_lookup))
.collect();
Ok(Json(users))
}
#[oai(
path = "/user-tokens/:id",
method = "get",
operation_id = "get_user_tokens"
)]
async fn get_user_tokens(
&self,
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessTokenModel>>> {
let target_user_id = id.0;
let tokens = AccessTokenModel::get_user_api_tokens(target_user_id).await?;
if context.user.id == target_user_id {
return Ok(Json(tokens));
}
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(tokens))
}
#[oai(path = "/users/:id", method = "delete", operation_id = "remove_user")]
async fn remove_user(
&self,
/// The User ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserModel::remove(id).await?)
}
#[oai(path = "/users", method = "post", operation_id = "create_user")]
async fn create_user(
&self,
payload: Json<UserCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserView>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let user = UserModel::create(payload.0).await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(user.to_view(&role_lookup)))
}
#[oai(path = "/users/:id", method = "post", operation_id = "update_user")]
async fn update_user(
&self,
id: Path<u64>,
payload: Json<UserUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let target_id = id.0;
let current_user_id = context.user.id;
if current_user_id != target_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
let mut update_data = payload.0;
if current_user_id == target_id
&& !context.has_permission(None, Permission::USER_MANAGE).await
{
update_data.global_roles = None;
update_data.account_access_map = None;
update_data.acl = None;
}
Ok(UserModel::update(target_id, update_data).await?)
}
#[oai(
path = "/current-user",
method = "get",
operation_id = "get_current_user"
)]
async fn get_current_user(&self, context: ClientContext) -> ApiResult<Json<UserView>> {
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(context.user.to_view(&role_lookup)))
}
#[oai(
path = "/minimal-user-list",
method = "get",
operation_id = "get_minimal_user_list"
)]
async fn get_minimal_user_list(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalUser>>> {
let is_admin = context.user.is_admin().await;
let minimal_list = MinimalUser::list_all().await?;
if is_admin {
return Ok(Json(minimal_list));
}
context
.require_permission(None, Permission::USER_VIEW)
.await?;
Ok(Json(minimal_list))
}
}
+1 -2
View File
@@ -78,8 +78,7 @@ pub async fn start_http_server() -> BichonResult<()> {
.with(Timeout)
.with(Tracing);
let cors_origins: Option<HashSet<String>> =
SETTINGS.bichon_cors_origins.clone();
let cors_origins: Option<HashSet<String>> = SETTINGS.bichon_cors_origins.clone();
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
+27 -15
View File
@@ -16,29 +16,41 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::token::root::check_root_password;
use poem::{handler, IntoResponse, Response};
use crate::modules::users::UserModel;
use poem::{handler, web::Json, IntoResponse, Response};
use serde::Deserialize;
use tracing::error;
/// Login endpoint for Root user
#[derive(Deserialize)]
pub struct LoginPayload {
pub username: String,
pub password: String,
}
/// Login endpoint
///
/// Accepts a plain text password and returns the `root_token`
/// on successful authentication.
#[handler]
pub async fn login(password: String) -> Response {
match check_root_password(&password) {
Ok(root_token) => Response::builder()
.status(http::StatusCode::OK)
.content_type("text/plain")
.body(root_token)
.into_response(),
pub async fn login(payload: Json<LoginPayload>) -> Response {
let payload = payload.0;
match UserModel::authenticate_user(payload.username, payload.password).await {
Ok(result) => match serde_json::to_string(&result) {
Ok(json_string) => Response::builder()
.status(http::StatusCode::OK)
.content_type("application/json")
.body(json_string)
.into_response(),
Err(_) => Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Internal server error during response serialization.")
.into_response(),
},
Err(e) => {
error!("Root login failed: {:?}", e);
error!("Authentication failed with system error: {:?}", e);
Response::builder()
.status(http::StatusCode::UNAUTHORIZED)
.content_type("text/plain")
.body(e.to_string())
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Authentication system failed.".to_string())
.into_response()
}
}
+32 -17
View File
@@ -19,7 +19,7 @@
use clap::{builder::ValueParser, Parser, ValueEnum};
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::parse);
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::init);
#[derive(Debug, Parser)]
#[clap(
@@ -132,11 +132,27 @@ pub struct Settings {
/// bichon encryption password
#[clap(
long,
default_value = "change-this-default-password-now",
env,
help = "Set the encryption password for bichon. ⚠️ Change this default in production!"
default_value = "change-this-default-password-now",
help = "Set the encryption password for bichon. Alternatively, you can use --bichon-encrypt-password-file. If both are set, this parameter takes precedence over the file."
)]
pub bichon_encrypt_password: String,
pub bichon_encrypt_password: Option<String>,
#[clap(
long,
env,
help = "The file containing the encryption password. An alternative to --bichon-encrypt-password."
)]
pub bichon_encrypt_password_file: Option<String>,
/// WebUI token expiration time in seconds (default: 7 days)
#[clap(
long,
default_value = "168",
env,
help = "Set the WebUI token expiration time in hours"
)]
pub bichon_webui_token_expiration_hours: u32,
#[clap(
long,
@@ -174,19 +190,6 @@ pub struct Settings {
)]
pub bichon_envelope_cache_size: Option<usize>,
/// Enables or disables the access token mechanism for HTTP endpoints.
///
/// When set to `true`, HTTP requests will be subject to access token validation.
/// If the `Authorization` header is missing or the token is invalid, the service will return a 401 Unauthorized response.
/// When set to `false`, access token validation will be skipped.
#[clap(
long,
default_value = "false",
env,
help = "Enables or disables the access token mechanism for HTTP endpoints."
)]
pub bichon_enable_access_token: bool,
/// Enables or disables HTTPS for REST API endpoints.
///
/// When set to `true`, the REST API will use HTTPS with a valid SSL/TLS certificate for secure communication.
@@ -217,6 +220,18 @@ pub struct Settings {
pub bichon_sync_concurrency: Option<u16>,
}
impl Settings {
pub fn init() -> Self {
let s = Self::parse();
if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() {
panic!(
"One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set"
);
}
s
}
}
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub enum CompressionAlgorithm {
#[clap(name = "none")]
+60
View File
@@ -16,8 +16,68 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::settings::cli::Settings;
pub mod cli;
pub mod dir;
pub mod proxy;
pub mod system;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct SystemConfigurations {
pub bichon_log_level: String,
pub bichon_http_port: i32,
pub bichon_bind_ip: Option<String>,
pub bichon_public_url: String,
pub bichon_cors_origins: Option<Vec<String>>,
pub bichon_cors_max_age: i32,
pub bichon_ansi_logs: bool,
pub bichon_log_to_file: bool,
pub bichon_json_logs: bool,
pub bichon_max_server_log_files: usize,
pub bichon_encrypt_password_set: bool,
pub bichon_webui_token_expiration_hours: u32,
pub bichon_root_dir: String,
pub bichon_metadata_cache_size: Option<usize>,
pub bichon_envelope_cache_size: Option<usize>,
pub bichon_enable_rest_https: bool,
pub bichon_http_compression_enabled: bool,
pub bichon_sync_concurrency: Option<u16>,
}
impl From<&Settings> for SystemConfigurations {
fn from(s: &Settings) -> Self {
Self {
bichon_log_level: s.bichon_log_level.clone(),
bichon_http_port: s.bichon_http_port,
bichon_bind_ip: s.bichon_bind_ip.clone(),
bichon_public_url: s.bichon_public_url.clone(),
bichon_cors_origins: s
.bichon_cors_origins
.as_ref()
.map(|set| set.iter().cloned().collect()),
bichon_cors_max_age: s.bichon_cors_max_age,
bichon_ansi_logs: s.bichon_ansi_logs,
bichon_log_to_file: s.bichon_log_to_file,
bichon_json_logs: s.bichon_json_logs,
bichon_max_server_log_files: s.bichon_max_server_log_files,
bichon_encrypt_password_set: s.bichon_encrypt_password.is_some()
|| s.bichon_encrypt_password_file.is_some(),
bichon_webui_token_expiration_hours: s.bichon_webui_token_expiration_hours,
bichon_root_dir: s.bichon_root_dir.clone(),
bichon_metadata_cache_size: s.bichon_metadata_cache_size,
bichon_envelope_cache_size: s.bichon_envelope_cache_size,
bichon_enable_rest_https: s.bichon_enable_rest_https,
bichon_http_compression_enabled: s.bichon_http_compression_enabled,
bichon_sync_concurrency: s.bichon_sync_concurrency,
}
}
}
+1 -5
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
@@ -132,10 +131,7 @@ mod tests {
#[test]
fn test_valid_proxy_urls() {
let urls = vec![
"socks5://127.0.0.1:1080",
"http://127.0.0.1:8080",
];
let urls = vec!["socks5://127.0.0.1:1080", "http://127.0.0.1:8080"];
for url in urls {
let proxy = Proxy::new(url.to_string());
+26 -26
View File
@@ -17,10 +17,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{find_impl, upsert_impl};
use crate::modules::error::BichonResult;
use crate::utc_now;
// use crate::modules::database::manager::DB_MANAGER;
// use crate::modules::database::{find_impl, upsert_impl};
// use crate::modules::error::BichonResult;
// use crate::utc_now;
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
@@ -37,34 +37,34 @@ pub struct SystemSetting {
}
impl SystemSetting {
pub fn new(key: String, value: String) -> Self {
Self {
key,
value,
created_at: utc_now!(),
updated_at: utc_now!(),
}
}
// pub fn new(key: String, value: String) -> Self {
// Self {
// key,
// value,
// created_at: utc_now!(),
// updated_at: utc_now!(),
// }
// }
//overwrite
pub async fn set(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
// pub async fn set(&self) -> BichonResult<()> {
// upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
// }
pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
find_impl(DB_MANAGER.meta_db(), key)
}
// pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
// find_impl(DB_MANAGER.meta_db(), key)
// }
// pub async fn list() -> RustMailerResult<Vec<SystemSetting>> {
// list_all_impl(DB_MANAGER.metadata_db()).await
// }
pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
let setting = Self::get(key)?;
Ok(setting.map(|s| s.value))
}
// pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
// let setting = Self::get(key)?;
// Ok(setting.map(|s| s.value))
// }
pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
let setting = Self::new(key.to_string(), value);
setting.set().await
}
// pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
// let setting = Self::new(key.to_string(), value);
// setting.set().await
// }
}
+223 -259
View File
@@ -16,12 +16,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashMap;
use crate::modules::account::migration::AccountModel;
use crate::modules::database::delete_impl;
use super::error::code::ErrorCode;
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{
async_find_impl, delete_impl, filter_by_secondary_key_impl, with_transaction,
};
use crate::modules::database::{insert_impl, list_all_impl, update_impl};
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::UserModel;
use crate::raise_error;
use crate::{
generate_token, modules::error::BichonResult,
@@ -29,259 +34,227 @@ use crate::{
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::net::IpAddr;
use super::error::code::ErrorCode;
pub mod payload;
pub mod root;
pub mod view;
// Starting from version 0.2.0, this model is deprecated/no longer used
// #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
// #[native_model(id = 1, version = 1)]
// #[native_db]
// pub struct AccessToken {
// /// The unique token string used for authentication
// #[primary_key]
// pub token: String,
// /// A set of account information associated with the token.
// pub accounts: BTreeSet<AccountInfo>,
// /// The timestamp (in milliseconds since epoch) when the token was created.
// pub created_at: i64,
// /// The timestamp (in milliseconds since epoch) when the token was last updated.
// pub updated_at: i64,
// /// An optional description of the token's purpose or usage.
// pub description: Option<String>,
// /// The timestamp (in milliseconds since epoch) when the token was last used.
// pub last_access_at: i64,
// /// Optional access control settings
// pub acl: Option<AccessControl>,
// }
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Enum)]
pub enum TokenType {
WebUI,
Api,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
#[native_model(id = 1, version = 1)]
#[native_model(id = 11, version = 1)]
#[native_db]
pub struct AccessToken {
pub struct AccessTokenModel {
/// The ID of the user who owns this token
#[secondary_key]
pub user_id: u64,
/// The unique token string used for authentication
#[primary_key]
pub token: String,
/// A set of account information associated with the token.
pub accounts: BTreeSet<AccountInfo>,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// An optional description of the token's purpose or usage.
pub description: Option<String>,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccountInfo {
/// The unique identifier for the account.
pub id: u64,
/// The email address associated with the account.
pub email: String,
}
impl Ord for AccountInfo {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl PartialOrd for AccountInfo {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessControl {
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
pub ip_whitelist: Option<BTreeSet<String>>,
/// An optional rate limit configuration for the access token.
pub rate_limit: Option<RateLimit>,
}
impl AccessControl {
pub fn validate(&self) -> BichonResult<()> {
if let Some(ip_whitelist) = &self.ip_whitelist {
for ip in ip_whitelist {
if ip.parse::<IpAddr>().is_err() {
return Err(raise_error!(
format!("Invalid IP address: {}", ip),
ErrorCode::InvalidParameter
));
}
}
}
// Validate rate limit
if let Some(rate_limit) = &self.rate_limit {
if rate_limit.interval < 1 {
return Err(raise_error!(
"Rate limit interval must be at least 1 second".into(),
ErrorCode::InvalidParameter
));
}
if rate_limit.quota < 1 {
return Err(raise_error!(
"Rate limit quota must be at least 1".into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct RateLimit {
/// The time window in seconds for the rate limit.
pub interval: u64,
/// The maximum number of allowed requests within the time window.
pub quota: u32,
}
impl AccessToken {
pub fn new(
impl AccessTokenModel {
pub fn new_api_token(
token: String,
accounts: BTreeSet<AccountInfo>,
description: Option<String>,
acl: Option<AccessControl>,
user_id: u64,
name: Option<String>,
expire_at: Option<i64>,
) -> Self {
Self {
token,
accounts,
created_at: utc_now!(),
updated_at: utc_now!(),
description,
last_access_at: Default::default(),
acl,
name,
user_id,
token_type: TokenType::Api,
expire_at,
}
}
pub async fn try_update_access_timestamp(token: &str) -> BichonResult<AccessToken> {
let token = token.to_string();
update_impl(
DB_MANAGER.meta_db(),
|rw| {
rw.get()
.primary::<AccessToken>(token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!("Token not exist.".into(), ErrorCode::ResourceNotFound)
})
},
|current| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
},
)
.await
pub fn new_webui_token(user_id: u64) -> AccessTokenModel {
let now = utc_now!();
AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: None,
user_id,
token_type: TokenType::WebUI,
expire_at: None,
}
}
pub async fn grant_account_access(token: &str, account: AccountInfo) -> BichonResult<()> {
let token = token.to_string();
update_impl(
pub async fn reset_webui_token(user_id: u64) -> BichonResult<String> {
let old_token = Self::get_user_webui_token(user_id).await?;
let new_token = Self::new_webui_token(user_id);
let new_token_str = new_token.token.clone();
match old_token {
Some(old) => {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
rw.remove(old)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.insert(new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
}
None => {
insert_impl(DB_MANAGER.meta_db(), new_token).await?;
}
}
Ok(new_token_str)
}
pub async fn get_user_webui_token(user_id: u64) -> BichonResult<Option<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The access token with token={} that you want to modify was not found.",
token
),
ErrorCode::ResourceNotFound
)
})
},
|current| {
let mut updated = current.clone();
updated.accounts.insert(account);
updated.updated_at = utc_now!();
Ok(updated)
},
AccessTokenModelKey::user_id,
user_id,
)
.await?;
Ok(())
Ok(tokens
.into_iter()
.find(|t| t.token_type == TokenType::WebUI))
}
pub async fn update(token: &str, request: AccessTokenUpdateRequest) -> BichonResult<()> {
if request.should_skip_update() {
return Err(raise_error!(
"No changes detected in access scopes, description, or accounts. \
Please modify at least one of these fields to perform an update."
.into(),
ErrorCode::InvalidParameter
));
}
request.validate().await?;
pub async fn get_user_api_tokens(user_id: u64) -> BichonResult<Vec<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
AccessTokenModelKey::user_id,
user_id,
)
.await?;
let account_infos = if let Some(accounts) = &request.accounts {
let mut account_infos = BTreeSet::new();
for account_id in accounts {
let account = AccountModel::get(*account_id).await?;
account_infos.insert(AccountInfo {
id: *account_id,
email: account.email,
});
Ok(tokens
.into_iter()
.filter(|t| t.token_type == TokenType::Api)
.collect())
}
pub async fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
let token = token.to_string();
let token_option = async_find_impl::<AccessTokenModel>(DB_MANAGER.meta_db(), token).await?;
let token = match token_option {
Some(token) => token,
None => {
return Err(raise_error!(
"Permission denied: no valid access token provided.".into(),
ErrorCode::PermissionDenied
))
}
account_infos
} else {
BTreeSet::new()
};
let token = token.to_string();
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The access token with token={} that you want to modify was not found.",
token
),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(description) = request.description {
updated.description = Some(description);
}
if matches!(token.token_type, TokenType::WebUI) {
let life = utc_now!() - token.created_at;
let max_life = SETTINGS.bichon_webui_token_expiration_hours * 60 * 60 * 1000;
if request.accounts.is_some() {
updated.accounts = account_infos;
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
pub async fn create(request: AccessTokenCreateRequest) -> BichonResult<String> {
// Validate request parameters first
request.validate().await?;
let AccessTokenCreateRequest {
accounts,
description,
acl,
} = request;
let mut account_infos = BTreeSet::new();
for &account_id in &accounts {
let account = AccountModel::get(account_id).await?;
account_infos.insert(AccountInfo {
id: account_id,
email: account.email,
});
if life > (max_life as i64) {
return Err(raise_error!(
"Permission denied: the WebUI token has expired.".into(),
ErrorCode::PermissionDenied
));
}
}
if matches!(token.token_type, TokenType::Api) {
if let Some(expire_at) = token.expire_at {
if utc_now!() > expire_at {
return Err(raise_error!(
"Your API token has expired and is no longer valid.".into(),
ErrorCode::PermissionDenied
));
}
}
let token = token.token.clone();
update_impl(
DB_MANAGER.meta_db(),
|rw| {
rw.get()
.primary::<AccessTokenModel>(token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
"The access token does not exist or has been reset.".into(),
ErrorCode::ResourceNotFound
)
})
},
|current| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
},
)
.await?;
}
let user = UserModel::find(token.user_id)
.await?
.ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))?;
Ok(user)
}
pub async fn create_api_token(
user_id: u64,
request: AccessTokenCreateRequest,
) -> BichonResult<String> {
// Validate request parameters first
request.validate().await?;
let expire_at = request
.expire_in
.map(|hours| utc_now!() + (hours as i64) * 60 * 60 * 1000);
let token = generate_token!(128);
let access_token = AccessToken::new(token.clone(), account_infos, description, acl);
let access_token =
AccessTokenModel::new_api_token(token.clone(), user_id, request.name, expire_at);
insert_impl(DB_MANAGER.meta_db(), access_token).await?;
Ok(token)
}
@@ -290,7 +263,7 @@ impl AccessToken {
let token = token.to_string();
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.primary::<AccessTokenModel>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -302,56 +275,47 @@ impl AccessToken {
.await
}
pub async fn list_all() -> BichonResult<Vec<AccessToken>> {
list_all_impl(DB_MANAGER.meta_db()).await
pub async fn get_token(token: &str) -> BichonResult<AccessTokenModel> {
async_find_impl(DB_MANAGER.meta_db(), token.to_string())
.await?
.ok_or_else(|| {
raise_error!(
format!("Access token '{}' not found", token),
ErrorCode::ResourceNotFound
)
})
}
pub async fn list_account_tokens(account_id: u64) -> BichonResult<Vec<AccessToken>> {
let all = AccessToken::list_all().await?;
let result: Vec<AccessToken> = all
pub async fn list_all_api_tokens() -> BichonResult<Vec<AccessTokenResp>> {
let users = UserModel::list_all().await?;
let mut all = list_all_impl::<AccessTokenModel>(DB_MANAGER.meta_db()).await?;
all.retain(|t| t.token_type == TokenType::Api);
let user_map: HashMap<u64, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
let resp = all
.into_iter()
.filter(|e| {
e.accounts
.iter()
.any(|account_info| account_info.id == account_id)
.map(|token| {
let user = user_map.get(&token.user_id);
AccessTokenResp {
user_name: user
.map(|u| u.username.clone())
.unwrap_or_else(|| "Unknown".to_string()),
user_email: user
.map(|u| u.email.clone())
.unwrap_or_else(|| "N/A".to_string()),
user_id: token.user_id,
name: token.name,
token: token.token,
token_type: token.token_type,
created_at: token.created_at,
updated_at: token.updated_at,
expire_at: token.expire_at,
last_access_at: token.last_access_at,
}
})
.collect();
Ok(result)
}
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
let tokens = Self::list_account_tokens(account_id).await?;
if tokens.is_empty() {
return Ok(());
}
for token in tokens {
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("Cannot find access token, {}", token.token),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
updated.updated_at = utc_now!();
updated.accounts.retain(|account| account.id != account_id);
Ok(updated)
},
)
.await?;
}
Ok(())
}
pub fn can_access_account(&self, account_id: u64) -> bool {
self.accounts.iter().any(|account| account.id == account_id)
Ok(resp)
}
}
+13 -83
View File
@@ -16,15 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::{
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
token::AccessControl,
},
modules::error::{code::ErrorCode, BichonResult},
raise_error,
};
use poem_openapi::Object;
@@ -32,91 +25,28 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize, Object)]
pub struct AccessTokenCreateRequest {
/// A set of account information associated with the token.
pub accounts: BTreeSet<u64>,
/// An optional description of the token's purpose or usage.
#[oai(validator(max_length = "255"))]
pub description: Option<String>,
/// Optional access control settings
pub acl: Option<AccessControl>,
#[oai(validator(max_length = "32"))]
pub name: Option<String>,
/// The expiration interval for this token, in hours.
/// None means the token does not expire (this applies only to API tokens).
pub expire_in: Option<u64>,
/// The ID of the user for whom the token is being created.
/// If not specified, the token will be created for the current authenticated user.
/// Accessing this for another user typically requires `USER_MANAGE` permissions.
pub user_id: Option<u64>,
}
impl AccessTokenCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(acl) = &self.acl {
acl.validate()?;
}
if self.accounts.is_empty() {
return Err(raise_error!(
"Account list cannot be empty. Please provide at least one valid account ID."
.into(),
ErrorCode::InvalidParameter
));
}
let mut not_found = Vec::new();
for account_id in &self.accounts {
if AccountModel::find(*account_id).await?.is_none() {
not_found.push(*account_id);
}
}
if !not_found.is_empty() {
return Err(raise_error!(
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
ErrorCode::InvalidParameter
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Object)]
pub struct AccessTokenUpdateRequest {
/// A set of account information associated with the token.
pub accounts: Option<BTreeSet<u64>>,
/// An optional description of the token's purpose or usage.
#[oai(validator(max_length = "255"))]
pub description: Option<String>,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
impl AccessTokenUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(accounts) = &self.accounts {
if accounts.is_empty() {
if let Some(expire_in) = self.expire_in {
if expire_in == 0 {
return Err(raise_error!(
"Account list cannot be empty. Please provide at least one valid account ID."
.into(),
"expire_in must be a positive duration in hours; zero is not allowed.".into(),
ErrorCode::InvalidParameter
));
}
let mut not_found = Vec::new();
for account_id in accounts {
if AccountModel::find(*account_id).await?.is_none() {
not_found.push(*account_id);
}
}
if !not_found.is_empty() {
return Err(raise_error!(
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
impl AccessTokenUpdateRequest {
pub fn should_skip_update(&self) -> bool {
self.description.is_none() && self.accounts.is_none() && self.acl.is_none()
}
}
+94 -95
View File
@@ -16,110 +16,109 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// use crate::{
// decrypt, encrypt, generate_token,
// modules::{
// error::{code::ErrorCode, BichonResult},
// settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
// },
// raise_error,
// };
// use std::fs::File;
// use std::io::Write;
use crate::{
decrypt, encrypt, generate_token,
modules::{
error::{code::ErrorCode, BichonResult},
settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
},
raise_error,
};
use std::fs::File;
use std::io::Write;
// pub const ROOT_TOKEN: &str = "root-token";
// pub const ROOT_PASSWORD: &str = "root-password";
// pub const DEFAULT_ROOT_PASSWORD: &str = "root";
// pub const ROOT_TOKEN_FILE: &str = "root";
pub const ROOT_TOKEN: &str = "root-token";
pub const ROOT_PASSWORD: &str = "root-password";
pub const DEFAULT_ROOT_PASSWORD: &str = "root";
pub const ROOT_TOKEN_FILE: &str = "root";
// async fn get_or_generate(
// key: &str,
// generate: impl Fn() -> String,
// save_file_name: Option<&str>,
// force: bool,
// ) -> BichonResult<String> {
// if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
// if force {
// // If force is true, write the existing value to the file
// if let Some(filename) = save_file_name {
// save_to_file(&existing_value.to_string(), filename).await?;
// }
// }
// Ok(existing_value)
// } else {
// // If no value exists, generate a new value
// let new_value = generate();
// SystemSetting::set_value(key, new_value.clone()).await?;
async fn get_or_generate(
key: &str,
generate: impl Fn() -> String,
save_file_name: Option<&str>,
force: bool,
) -> BichonResult<String> {
if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
if force {
// If force is true, write the existing value to the file
if let Some(filename) = save_file_name {
save_to_file(&existing_value.to_string(), filename).await?;
}
}
Ok(existing_value)
} else {
// If no value exists, generate a new value
let new_value = generate();
SystemSetting::set_value(key, new_value.clone()).await?;
// // Write the new value to the file, if specified
// if let Some(filename) = save_file_name {
// save_to_file(&new_value.to_string(), filename).await?;
// }
// Ok(new_value)
// }
// }
// Write the new value to the file, if specified
if let Some(filename) = save_file_name {
save_to_file(&new_value.to_string(), filename).await?;
}
Ok(new_value)
}
}
// pub async fn ensure_root_token() -> BichonResult<()> {
// get_or_generate(
// ROOT_TOKEN,
// || generate_token!(128),
// Some(ROOT_TOKEN_FILE),
// true,
// )
// .await?;
// Ok(())
// }
pub async fn ensure_root_token() -> BichonResult<()> {
get_or_generate(
ROOT_TOKEN,
|| generate_token!(128),
Some(ROOT_TOKEN_FILE),
true,
)
.await?;
Ok(())
}
// pub async fn reset_root_token() -> BichonResult<String> {
// let new_token = generate_token!(128);
// save_new_token(&new_token).await?;
// save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
// Ok(new_token)
// }
pub async fn reset_root_token() -> BichonResult<String> {
let new_token = generate_token!(128);
save_new_token(&new_token).await?;
save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
Ok(new_token)
}
// async fn save_new_token(token: &str) -> BichonResult<()> {
// let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
// setting.set().await
// }
async fn save_new_token(token: &str) -> BichonResult<()> {
let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
setting.set().await
}
// async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
// let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
// let mut file = File::create(&file_path)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// writeln!(file, "{}", content)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(())
// }
async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
let mut file = File::create(&file_path)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
writeln!(file, "{}", content)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
// pub fn check_root_password(password: &str) -> BichonResult<String> {
// let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
// let matched = match stored_encrypted_password {
// Some(ref stored) => {
// let decrypted = decrypt!(stored)?;
// decrypted == password
// }
// None => DEFAULT_ROOT_PASSWORD == password,
// };
pub fn check_root_password(password: &str) -> BichonResult<String> {
let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
let matched = match stored_encrypted_password {
Some(ref stored) => {
let decrypted = decrypt!(stored)?;
decrypted == password
}
None => DEFAULT_ROOT_PASSWORD == password,
};
// if !matched {
// return Err(raise_error!(
// "Invalid password".into(),
// ErrorCode::PermissionDenied
// ));
// }
if !matched {
return Err(raise_error!(
"Invalid password".into(),
ErrorCode::PermissionDenied
));
}
// let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
// raise_error!(
// "Root token not found — this should never happen".into(),
// ErrorCode::InternalError
// )
// })?;
let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
raise_error!(
"Root token not found — this should never happen".into(),
ErrorCode::InternalError
)
})?;
// Ok(root_token)
// }
Ok(root_token)
}
pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
let encrypted_password = encrypt!(new_password)?;
SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
}
// pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
// let encrypted_password = encrypt!(new_password)?;
// SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
// }
+44
View File
@@ -0,0 +1,44 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::token::TokenType;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessTokenResp {
pub user_id: u64,
pub token: String,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
pub user_name: String,
pub user_email: String,
}
+73
View File
@@ -0,0 +1,73 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{collections::BTreeSet, net::IpAddr};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{modules::error::{BichonResult, code::ErrorCode}, raise_error};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct RateLimit {
/// The time window in seconds for the rate limit.
pub interval: u64,
/// The maximum number of allowed requests within the time window.
pub quota: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessControl {
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
pub ip_whitelist: Option<BTreeSet<String>>,
/// An optional rate limit configuration for the access token.
pub rate_limit: Option<RateLimit>,
}
impl AccessControl {
pub fn validate(&self) -> BichonResult<()> {
if let Some(ip_whitelist) = &self.ip_whitelist {
for ip in ip_whitelist {
if ip.parse::<IpAddr>().is_err() {
return Err(raise_error!(
format!("Invalid IP address: {}", ip),
ErrorCode::InvalidParameter
));
}
}
}
// Validate rate limit
if let Some(rate_limit) = &self.rate_limit {
if rate_limit.interval < 1 {
return Err(raise_error!(
"Rate limit interval must be at least 1 second".into(),
ErrorCode::InvalidParameter
));
}
if rate_limit.quota < 1 {
return Err(raise_error!(
"Rate limit quota must be at least 1".into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
@@ -16,30 +16,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::{
context::Initialize,
error::BichonResult,
users::{role::UserRole, UserModel},
};
interface AccountInfo {
id: number;
email: string;
pub struct UserManager;
impl Initialize for UserManager {
async fn initialize() -> BichonResult<()> {
UserRole::ensure_default_roles_exists().await?;
UserModel::ensure_default_admin_exists().await
}
}
interface RateLimit {
quota: number;
interval: number;
}
interface AccessControl {
ip_whitelist?: string[];
rate_limit?: RateLimit;
}
interface AccessToken {
token: string;
accounts: AccountInfo[];
created_at: number;
updated_at: number;
description?: string;
last_access_at: number;
acl?: AccessControl;
}
export type { AccessToken, AccountInfo, AccessControl, RateLimit };
+49
View File
@@ -0,0 +1,49 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
database::{list_all_impl, manager::DB_MANAGER},
error::BichonResult,
users::BichonUser,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct MinimalUser {
pub id: u64,
pub username: String,
pub email: String,
}
impl MinimalUser {
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
let all_users = list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?;
let minimal_list = all_users
.into_iter()
.map(|user| MinimalUser {
id: user.id,
username: user.username,
email: user.email,
})
.collect();
Ok(minimal_list)
}
}
+695
View File
@@ -0,0 +1,695 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decrypt, encrypt, generate_token, id,
modules::{
database::{
async_find_impl, batch_delete_impl, delete_impl, list_all_impl, manager::DB_MANAGER,
secondary_find_impl, update_impl, with_transaction,
},
error::{code::ErrorCode, BichonResult},
token::{AccessTokenModel, AccessTokenModelKey, TokenType},
users::{
acl::AccessControl,
payload::{UserCreateRequest, UserUpdateRequest},
permissions::Permission,
role::{UserRole, DEFAULT_ADMIN_ROLE_ID},
view::UserView,
},
},
raise_error, utc_now,
};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use tracing::warn;
pub mod acl;
pub mod manager;
pub mod minimal;
pub mod payload;
pub mod permissions;
pub mod role;
pub mod view;
pub type UserModel = BichonUserV2;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct LoginResult {
pub success: bool,
pub error_message: Option<String>,
pub access_token: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 10, version = 1)]
#[native_db]
pub struct BichonUser {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 10, version = 2, from = BichonUser)]
#[native_db]
pub struct BichonUserV2 {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl BichonUserV2 {
pub async fn list_all() -> BichonResult<Vec<UserModel>> {
Ok(list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?)
}
async fn get_all_permissions(&self) -> HashSet<String> {
let mut all_perms = HashSet::new();
for &role_id in &self.global_roles {
if let Ok(Some(role)) = UserRole::find(role_id).await {
for perm in role.permissions {
all_perms.insert(perm);
}
}
}
all_perms
}
pub fn to_view(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
let global_roles_names = self
.global_roles
.iter()
.filter_map(|role_id| role_lookup.get(role_id))
.map(|role| role.name.clone())
.collect();
let account_roles_summary = self
.account_access_map
.iter()
.map(|(acc_id, role_id)| {
let role_name = role_lookup
.get(role_id)
.map(|r| r.name.clone())
.unwrap_or_else(|| "Unknown Role".to_string());
(*acc_id, role_name)
})
.collect();
let global_permissions = {
let mut perms = BTreeSet::new();
for role_id in &self.global_roles {
if let Some(role) = role_lookup.get(role_id) {
perms.extend(role.permissions.iter().cloned());
}
}
perms.into_iter().collect()
};
let account_permissions = {
let mut map: BTreeMap<u64, BTreeSet<String>> = BTreeMap::new();
for (account_id, role_id) in &self.account_access_map {
if let Some(role) = role_lookup.get(role_id) {
let entry = map.entry(*account_id).or_default();
entry.extend(role.permissions.iter().cloned());
}
}
map.into_iter()
.map(|(acc_id, perms)| (acc_id, perms.into_iter().collect()))
.collect()
};
UserView {
id: self.id,
username: self.username,
email: self.email,
password: self.password.map(|_| "************".to_string()),
account_access_map: self.account_access_map,
account_roles_summary,
description: self.description,
global_roles: self.global_roles,
global_roles_names,
avatar: self.avatar,
created_at: self.created_at,
updated_at: self.updated_at,
acl: self.acl,
account_permissions,
global_permissions,
theme: self.theme,
language: self.language,
}
}
pub async fn is_admin(&self) -> bool {
self.get_all_permissions().await.contains(Permission::ROOT)
}
pub async fn ensure_default_admin_exists() -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
// 1. Try to get the existing admin user
let admin = rw
.get()
.primary::<UserModel>(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if admin.is_none() {
// 2. Insert the BichonUser with the updated schema
rw.insert(UserModel {
id: DEFAULT_ADMIN_USER_ID,
username: "admin".into(),
email: "placeholder@example.com".into(),
password: Some(encrypt!("admin@bichon")?),
// Use global_roles as defined in our new schema
global_roles: vec![DEFAULT_ADMIN_ROLE_ID],
// Admin usually doesn't need specific scoped access
account_access_map: BTreeMap::new(),
avatar: None,
created_at: now,
updated_at: now,
description: Some("System default administrator".into()),
acl: None,
theme: None,
language: None,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// 3. Generate and insert an initial access token for the first-time setup
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Initial Setup Token".into()),
user_id: DEFAULT_ADMIN_USER_ID,
token_type: TokenType::WebUI,
expire_at: None, // Admin setup token usually persistent until changed
};
rw.upsert(access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await?;
Ok(())
}
pub async fn authenticate_user(
username: String,
password: String,
) -> BichonResult<LoginResult> {
let user_option = secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.clone(),
)
.await?;
let user = match user_option {
Some(u) => u,
None => {
match secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::email,
username,
)
.await?
{
Some(u) => u,
None => {
return Ok(LoginResult {
success: false,
error_message: Some("User or email not found.".to_string()),
access_token: None,
theme: None,
language: None,
});
}
}
}
};
match user.password.as_ref() {
Some(encrypted_password) => {
let decrypted = decrypt!(encrypted_password)?;
if password == decrypted {
let new_token = AccessTokenModel::reset_webui_token(user.id).await?;
Ok(LoginResult {
success: true,
error_message: None,
access_token: Some(new_token),
theme: user.theme,
language: user.language,
})
} else {
warn!(
"Login failed: Incorrect password for user '{}'.",
user.username
);
Ok(LoginResult {
success: false,
error_message: Some("Incorrect password.".to_string()),
access_token: None,
theme: None,
language: None,
})
}
}
None => {
warn!(
"Login failed: User '{}' has no password set.",
user.username
);
Ok(LoginResult {
success: false,
error_message: Some(
format!(
"User '{}' has no password set. Please try logging in with an alternative method (e.g., OAuth/SSO).",
user.username
)
),
access_token: None,
theme: None,
language: None,
})
}
}
}
pub async fn find(user_id: u64) -> BichonResult<Option<UserModel>> {
async_find_impl(DB_MANAGER.meta_db(), user_id).await
}
pub async fn check_username_conflict(username: &str) -> BichonResult<()> {
// Check username duplicate
if secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.to_string(),
)
.await?
.is_some()
{
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
ErrorCode::AlreadyExists
));
}
Ok(())
}
pub async fn check_email_conflict(email: &str) -> BichonResult<()> {
// Check email duplicate
if secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::email,
email.to_string(),
)
.await?
.is_some()
{
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
ErrorCode::AlreadyExists
));
}
Ok(())
}
pub async fn create(request: UserCreateRequest) -> BichonResult<UserModel> {
request.validate().await?;
Self::check_username_conflict(&request.username).await?;
Self::check_email_conflict(&request.email).await?;
let password_hash = Some(encrypt!(&request.password)?);
let now = utc_now!();
let user = UserModel {
id: id!(96),
username: request.username,
email: request.email,
password: password_hash,
global_roles: request.global_roles,
avatar: request.avatar_base64,
description: request.description,
acl: request.acl,
created_at: now,
updated_at: now,
account_access_map: request.account_access_map,
theme: request.theme,
language: request.language,
};
let user_clone = user.clone();
// 4. Atomic transaction for User and Initial Token
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let user_id = user.id;
// Insert User
rw.insert(user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Create initial WebUI access token
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Default WebUI Token".into()),
user_id,
token_type: TokenType::WebUI,
expire_at: None,
};
rw.insert(access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
Ok(user_clone)
}
//delete user
pub async fn remove(id: u64) -> BichonResult<()> {
if DEFAULT_ADMIN_USER_ID == id {
return Err(raise_error!(
format!("The default admin user (id={}) cannot be removed", id),
ErrorCode::PermissionDenied
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<BichonUser>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("The User with id={id} that you want to delete was not found."),
ErrorCode::ResourceNotFound
)
})
})
.await?;
batch_delete_impl(DB_MANAGER.meta_db(), move |rw| {
let tokens: Vec<AccessTokenModel> = rw
.scan()
.secondary::<AccessTokenModel>(AccessTokenModelKey::user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(tokens)
})
.await?;
Ok(())
}
pub async fn update(id: u64, request: UserUpdateRequest) -> BichonResult<()> {
let _ = &request.validate().await?;
let password_changed = request.password.is_some();
//
let is_default_admin = id == DEFAULT_ADMIN_USER_ID;
let is_valid_admin_roles = matches!(
request.global_roles.as_deref(),
Some([role]) if *role == DEFAULT_ADMIN_ROLE_ID
);
if is_default_admin && !is_valid_admin_roles {
return Err(raise_error!(format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id), ErrorCode::Forbidden));
}
if let Some(username) = &request.username {
let user_option = secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.to_string(),
)
.await?;
if let Some(u) = user_option {
if u.id != id {
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
ErrorCode::AlreadyExists
));
}
}
}
if let Some(email) = &request.email {
let user_option = secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::email,
email.to_string(),
)
.await?;
if let Some(u) = user_option {
if u.id != id {
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
ErrorCode::AlreadyExists
));
}
}
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<UserModel>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(username) = request.username {
updated.username = username;
}
if let Some(email) = request.email {
updated.email = email;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(password) = request.password {
updated.password = Some(encrypt!(&password)?);
}
if let Some(global_roles) = request.global_roles {
updated.global_roles = global_roles;
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
if let Some(account_access_map) = request.account_access_map {
updated.account_access_map = account_access_map;
}
if let Some(avatar_base64) = request.avatar_base64 {
updated.avatar = Some(avatar_base64);
}
if let Some(theme) = request.theme {
updated.theme = Some(theme);
}
if let Some(language) = request.language {
updated.language = Some(language);
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
if password_changed {
AccessTokenModel::reset_webui_token(id).await?;
}
Ok(())
}
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<UserModel>> {
let all = Self::list_all().await?;
let result: Vec<UserModel> = all
.into_iter()
.filter(|e| e.account_access_map.contains_key(&account_id))
.collect();
Ok(result)
}
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
let users = Self::list_authorized_users(account_id).await?;
if users.is_empty() {
return Ok(());
}
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
for user in users {
let current = rw
.get()
.primary::<UserModel>(user.id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User {} not found", user.id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated = current.clone();
if updated.account_access_map.remove(&account_id).is_some() {
updated.updated_at = now;
rw.update(current, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(())
}
}
impl From<BichonUserV2> for BichonUser {
fn from(value: BichonUserV2) -> Self {
BichonUser {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
}
}
}
impl From<BichonUser> for BichonUserV2 {
fn from(value: BichonUser) -> Self {
BichonUserV2 {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
theme: None,
language: None,
}
}
}
+453
View File
@@ -0,0 +1,453 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
users::{
acl::AccessControl,
permissions::{Permission, VALID_PERMISSION_SET},
role::{RoleType, UserRole},
},
utils::decode_avatar_bytes,
},
raise_error,
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
fn allowed_themes() -> HashSet<&'static str> {
["light", "dark"].into_iter().collect()
}
fn allowed_languages() -> HashSet<&'static str> {
[
"ar", "da", "de", "en", "es", "fi", "fr", "it", "jp", "ko", "nl", "no", "pl", "pt", "ru",
"sv", "zh", "zh-tw",
]
.into_iter()
.collect()
}
fn validate_option_in_set(
value: &Option<String>,
allowed: &std::collections::HashSet<&'static str>,
field_name: &str,
) -> BichonResult<()> {
if let Some(v) = value {
if !allowed.contains(v.as_str()) {
return Err(raise_error!(
format!("invalid {} value: '{}'", field_name, v),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
fn validate_theme(theme: &Option<String>) -> BichonResult<()> {
validate_option_in_set(theme, &allowed_themes(), "theme")
}
fn validate_language(language: &Option<String>) -> BichonResult<()> {
validate_option_in_set(language, &allowed_languages(), "language")
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RoleCreateRequest {
pub name: String,
pub role_type: RoleType,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
}
impl RoleCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
let trimmed_name = self.name.trim();
if trimmed_name.is_empty() {
return Err(raise_error!(
"Role name cannot be empty or consist only of whitespace.".into(),
ErrorCode::InvalidParameter
));
}
let name_lower = trimmed_name.to_lowercase();
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
return Err(raise_error!(
format!(
"The name '{}' is reserved for system builtin roles.",
trimmed_name
),
ErrorCode::InvalidParameter
));
}
if self.permissions.is_empty() {
return Err(raise_error!(
"Role must be assigned at least one permission.".into(),
ErrorCode::InvalidParameter
));
}
for permission in &self.permissions {
if !VALID_PERMISSION_SET.contains(permission.as_str()) {
return Err(raise_error!(
format!(
"Invalid permission '{}' specified in the request.",
permission
),
ErrorCode::InvalidParameter
));
}
}
Permission::validate_role_permissions(&self.role_type, &self.permissions)?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RoleUpdateRequest {
pub name: Option<String>,
pub description: Option<String>,
pub permissions: Option<BTreeSet<String>>,
}
impl RoleUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
// 1. Ensure at least one field is provided for the update
if self.name.is_none() && self.description.is_none() && self.permissions.is_none() {
return Err(raise_error!(
"Update request must contain at least one field to modify (name, description, or permissions).".into(),
ErrorCode::InvalidParameter
));
}
// 2. Validate Name if present
if let Some(name) = &self.name {
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
return Err(raise_error!(
"Role name cannot be set to an empty string or consist only of whitespace."
.into(),
ErrorCode::InvalidParameter
));
}
// Prevent renaming to reserved system names
let name_lower = trimmed_name.to_lowercase();
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
return Err(raise_error!(
format!(
"The name '{}' is reserved for system builtin roles.",
trimmed_name
),
ErrorCode::InvalidParameter
));
}
}
// 3. Validate Permissions if present
if let Some(permissions) = &self.permissions {
// Ensure the role doesn't end up with zero permissions
if permissions.is_empty() {
return Err(raise_error!(
"Permissions list cannot be empty. A role must have at least one permission."
.into(),
ErrorCode::InvalidParameter
));
}
// Check for invalid permission strings using a functional approach
if let Some(invalid_permission) = permissions
.iter()
.find(|p| !VALID_PERMISSION_SET.contains(p.as_str()))
{
return Err(raise_error!(
format!(
"Invalid permission '{}' specified in the update request.",
invalid_permission
),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct UserCreateRequest {
pub username: String,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub password: String,
/// Global Roles: System-wide permissions (e.g., Admin, User Manager).
pub global_roles: Vec<u64>,
/// Scoped Access: List of accounts paired with specific roles.
/// This allows different permissions per account.
pub account_access_map: BTreeMap<u64, u64>,
pub acl: Option<AccessControl>,
pub avatar_base64: Option<String>,
pub description: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl UserCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
let username_len = self.username.len();
// 1. Username constraints
if username_len < 5 {
return Err(raise_error!(
"Username must be at least 5 characters long.".into(),
ErrorCode::InvalidParameter
));
}
if username_len > 32 {
return Err(raise_error!(
"Username cannot exceed 32 characters.".into(),
ErrorCode::InvalidParameter
));
}
// 2. Password constraints
let password_len = self.password.len();
if password_len < 8 {
return Err(raise_error!(
"Password must be at least 8 characters long.".into(),
ErrorCode::InvalidParameter
));
}
if password_len > 256 {
return Err(raise_error!(
"Password cannot exceed 256 characters.".into(),
ErrorCode::InvalidParameter
));
}
// 3. Global Roles validation
if self.global_roles.is_empty() {
return Err(raise_error!(
"Global roles list cannot be empty. At least one role must be selected.".into(),
ErrorCode::InvalidParameter
));
}
validate_theme(&self.theme)?;
validate_language(&self.language)?;
let all_roles = UserRole::list_all().await?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
for rid in &self.global_roles {
match role_type_map.get(rid) {
Some(RoleType::Global) => {}
Some(_) => {
return Err(raise_error!(
format!("Role {} is not a System role", rid),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("System Role {} not found", rid),
ErrorCode::InvalidParameter
))
}
}
}
for (aid, rid) in &self.account_access_map {
if AccountModel::find(*aid).await?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter
));
}
match role_type_map.get(rid) {
Some(RoleType::Account) => {}
Some(_) => {
return Err(raise_error!(
format!(
"Role {} assigned to account {} must be an Account role",
rid, aid
),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("Role {} for account {} not found", rid, aid),
ErrorCode::InvalidParameter
))
}
}
}
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(desc) = &self.description {
if desc.len() > 256 {
return Err(raise_error!(
"Description cannot exceed 256 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(avatar_base64) = &self.avatar_base64 {
decode_avatar_bytes(&avatar_base64)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct UserUpdateRequest {
pub username: Option<String>,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: Option<String>,
pub password: Option<String>,
pub avatar_base64: Option<String>,
pub global_roles: Option<Vec<u64>>,
/// Scoped Access
pub account_access_map: Option<BTreeMap<u64, u64>>,
pub acl: Option<AccessControl>,
pub description: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl UserUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(username) = &self.username {
let len = username.len();
if len < 5 || len > 32 {
return Err(raise_error!(
"Username must be 5-32 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(password) = &self.password {
let len = password.len();
if len < 8 || len > 256 {
return Err(raise_error!(
"Password must be 8-256 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
validate_theme(&self.theme)?;
validate_language(&self.language)?;
let all_roles = UserRole::list_all().await?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
if let Some(roles) = &self.global_roles {
if roles.is_empty() {
return Err(raise_error!(
"Roles list cannot be empty.".into(),
ErrorCode::InvalidParameter
));
}
for role_id in roles {
match role_type_map.get(role_id) {
Some(RoleType::Global) => {}
Some(_) => {
return Err(raise_error!(
format!("Role {} is not a System role", role_id),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("System Role {} not found", role_id),
ErrorCode::InvalidParameter
))
}
}
}
}
if let Some(account_access_map) = &self.account_access_map {
for (aid, rid) in account_access_map {
if AccountModel::find(*aid).await?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter
));
}
match role_type_map.get(rid) {
Some(RoleType::Account) => {}
Some(_) => {
return Err(raise_error!(
format!(
"Role {} assigned to account {} must be an Account role",
rid, aid
),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("Role {} for account {} not found", rid, aid),
ErrorCode::InvalidParameter
))
}
}
}
}
if let Some(desc) = &self.description {
if desc.len() > 256 {
return Err(raise_error!(
"Description too long.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(avatar) = &self.avatar_base64 {
decode_avatar_bytes(avatar)?;
}
Ok(())
}
}
+239
View File
@@ -0,0 +1,239 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
collections::{BTreeSet, HashSet},
sync::LazyLock,
};
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
users::role::RoleType,
},
raise_error,
};
pub static VALID_PERMISSION_SET: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
Permission::all_permissions()
.into_iter()
.map(|(key, _)| key)
.collect()
});
pub struct Permission;
impl Permission {
// ----------------------------------------------------------------------
// 1. Global Management Permissions (System, Users, Tokens)
// ----------------------------------------------------------------------
/// Basic platform access. Required for any user to log in and access the dashboard.
/// This provides no administrative powers.
pub const SYSTEM_ACCESS: &str = "system:access";
/// Manage core system configurations (OAuth Client ID/Secret, Proxy settings).
pub const ROOT: &str = "system:root";
/// Create, modify, and delete all users and their roles (Admin only).
pub const USER_MANAGE: &str = "user:manage";
/// View the minimal user list and basic profiles (Managers and Admins).
pub const USER_VIEW: &str = "user:view";
/// View and revoke all access tokens in the system.
pub const TOKEN_MANAGE: &str = "token:manage";
/// Create new email account connections.
pub const ACCOUNT_CREATE: &str = "account:create";
// ----------------------------------------------------------------------
// 2. Global "ALL" Scoped Permissions (Reserved for Admin)
// ----------------------------------------------------------------------
/// Manage configuration for all accounts (Global control).
pub const ACCOUNT_MANAGE_ALL: &str = "account:manage:all";
/// Read mail data from all accounts (Search, view messages).
pub const DATA_READ_ALL: &str = "data:read:all";
/// Download raw EML/MIME files from all accounts.
pub const DATA_RAW_DOWNLOAD_ALL: &str = "data:raw:download:all";
/// Delete messages from all accounts.
pub const DATA_DELETE_ALL: &str = "data:delete:all";
/// Manage metadata (e.g., tags, categories, notes) for messages in ALL email accounts.
pub const DATA_MANAGE_ALL: &str = "data:manage:all";
/// Export messages in batches from all accounts.
pub const DATA_EXPORT_BATCH_ALL: &str = "data:export:batch:all";
// ----------------------------------------------------------------------
// 3. Scoped/Limited Permissions (Manager & Viewer)
// Authorization requires checking the user's Account Access List (ACL)
// ----------------------------------------------------------------------
/// Manage (modify/delete/sync) configuration for a specific set of accounts.
pub const ACCOUNT_MANAGE: &str = "account:manage";
/// Read details and sync status for a specific set of accounts.
pub const ACCOUNT_READ_DETAILS: &str = "account:read_details";
/// Manage mail data metadata (e.g., updating tags, adding notes)
/// for specific accounts.
pub const DATA_MANAGE: &str = "data:manage";
/// Read mail data (Search, view) from a specific set of accounts.
pub const DATA_READ: &str = "data:read";
/// Download raw EML/MIME files from a specific set of accounts.
pub const DATA_RAW_DOWNLOAD: &str = "data:raw:download";
/// Delete messages from a specific set of accounts.
pub const DATA_DELETE: &str = "data:delete";
/// Export messages in batches from a specific set of accounts.
pub const DATA_EXPORT_BATCH: &str = "data:export:batch";
/// Import EML/PST data into a SPECIFIC account.
/// Authorization requires checking access to the target account_id.
pub const DATA_IMPORT_BATCH: &str = "data:import:batch";
pub fn global_permissions() -> Vec<(&'static str, &'static str)> {
vec![
(
Self::SYSTEM_ACCESS,
"Basic platform access for dashboard and personal settings.",
),
(Self::ROOT, "Full system access and configuration."),
(Self::USER_MANAGE, "Create, update, and delete users."),
(
Self::USER_VIEW,
"Read-only access to user list and profiles.",
),
(Self::TOKEN_MANAGE, "View and revoke all active API tokens."),
(
Self::ACCOUNT_CREATE,
"Connect new email accounts to the system.",
),
(
Self::ACCOUNT_MANAGE_ALL,
"Manage configurations for all email accounts.",
),
(
Self::DATA_READ_ALL,
"Search and read messages across all accounts.",
),
(
Self::DATA_MANAGE_ALL,
"Manage metadata and tags for all accounts.",
),
(
Self::DATA_RAW_DOWNLOAD_ALL,
"Download raw EML data from any account.",
),
(
Self::DATA_DELETE_ALL,
"Permanently delete messages from any account.",
),
(
Self::DATA_EXPORT_BATCH_ALL,
"Export bulk message data from all accounts.",
),
]
}
pub fn account_permissions() -> Vec<(&'static str, &'static str)> {
vec![
(
Self::ACCOUNT_MANAGE,
"Update or sync settings for authorized accounts.",
),
(
Self::ACCOUNT_READ_DETAILS,
"View status and details of authorized accounts.",
),
(
Self::DATA_READ,
"Read messages from authorized email accounts.",
),
(
Self::DATA_MANAGE,
"Manage tags and metadata for authorized accounts.",
),
(
Self::DATA_RAW_DOWNLOAD,
"Download raw EML files from authorized accounts.",
),
(
Self::DATA_DELETE,
"Delete messages from authorized email accounts.",
),
(
Self::DATA_EXPORT_BATCH,
"Export messages from authorized accounts.",
),
(
Self::DATA_IMPORT_BATCH,
"Import external EML/PST data into authorized accounts.",
),
]
}
pub fn all_permissions() -> Vec<(&'static str, &'static str)> {
let mut all = Self::global_permissions();
all.extend(Self::account_permissions());
all
}
fn is_account_permission(perm: &str) -> bool {
Self::account_permissions().iter().any(|(p, _)| *p == perm)
}
fn is_global_permission(perm: &str) -> bool {
Self::global_permissions().iter().any(|(p, _)| *p == perm)
}
pub fn validate_role_permissions(
role_type: &RoleType,
permissions: &BTreeSet<String>,
) -> BichonResult<()> {
for p in permissions {
match role_type {
RoleType::Global => {
if !Self::is_global_permission(p) {
return Err(raise_error!(
format!("Permission '{}' is not a valid Global permission", p),
ErrorCode::InvalidParameter
));
}
}
RoleType::Account => {
if !Self::is_account_permission(p) {
return Err(raise_error!(
format!("Permission '{}' is not a valid Account permission", p),
ErrorCode::InvalidParameter
));
}
}
}
}
Ok(())
}
}
+359
View File
@@ -0,0 +1,359 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
collections::{BTreeSet, HashSet},
fmt::{self, Display},
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use crate::{
id,
modules::{
database::{
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
update_impl, with_transaction,
},
error::{code::ErrorCode, BichonResult},
users::{
payload::{RoleCreateRequest, RoleUpdateRequest},
permissions::*,
},
},
raise_error, utc_now,
};
/// Enumerates the built-in roles in the Bichon system.
#[derive(Debug, PartialEq, Eq, Hash)]
pub enum BuiltinRole {
Admin,
Manager,
Member,
AccountManager,
AccountViewer,
}
impl Display for BuiltinRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
BuiltinRole::Admin => "admin",
BuiltinRole::Manager => "manager",
BuiltinRole::Member => "member",
BuiltinRole::AccountManager => "account_manager",
BuiltinRole::AccountViewer => "account_viewer",
};
write!(f, "{}", s)
}
}
impl BuiltinRole {
pub fn description(&self) -> &'static str {
match self {
BuiltinRole::Admin => {
"Full system administrator with unrestricted access to all accounts, user management, and system configurations."
}
BuiltinRole::Manager => {
"Standard operational manager. Can manage users, create accounts, and perform data operations on authorized email accounts."
}
BuiltinRole::Member => {
"Regular platform member. Provides basic login access to the system without any administrative or global management privileges."
}
BuiltinRole::AccountManager => {
"Specific account manager. Has full administrative control over a particular email account, including configuration and data deletion."
}
BuiltinRole::AccountViewer => {
"Specific account observer. Has read-only access to messages and metadata for a particular email account."
}
}
}
/// Retrieves the set of static permissions associated with the role.
pub fn get_permissions(&self) -> HashSet<&'static str> {
match self {
BuiltinRole::Admin => Self::admin_permissions(),
BuiltinRole::Manager => Self::manager_permissions(),
BuiltinRole::Member => Self::member_permissions(),
BuiltinRole::AccountManager => Self::account_owner_permissions(),
BuiltinRole::AccountViewer => Self::account_viewer_permissions(),
}
}
/// Admin Role: Full control over the system and all data.
fn admin_permissions() -> HashSet<&'static str> {
[
// System-Wide
Permission::ROOT,
Permission::USER_MANAGE,
Permission::USER_VIEW,
Permission::TOKEN_MANAGE,
// Account Configuration
Permission::ACCOUNT_CREATE,
Permission::ACCOUNT_MANAGE_ALL, // Global account management
// Data Access (Global ALL)
Permission::DATA_READ_ALL,
Permission::DATA_MANAGE_ALL,
Permission::DATA_RAW_DOWNLOAD_ALL,
Permission::DATA_DELETE_ALL,
Permission::DATA_EXPORT_BATCH_ALL,
]
.into_iter()
.collect()
}
/// Manager Role: Data and account configuration management, limited user management.
/// ALL data/account access must be scoped by the user's ACL.
fn manager_permissions() -> HashSet<&'static str> {
[Permission::USER_VIEW, Permission::ACCOUNT_CREATE]
.into_iter()
.collect()
}
fn member_permissions() -> HashSet<&'static str> {
[Permission::SYSTEM_ACCESS].into_iter().collect()
}
fn account_owner_permissions() -> HashSet<&'static str> {
[
Permission::ACCOUNT_MANAGE,
Permission::ACCOUNT_READ_DETAILS,
Permission::DATA_READ,
Permission::DATA_MANAGE,
Permission::DATA_RAW_DOWNLOAD,
Permission::DATA_DELETE,
Permission::DATA_EXPORT_BATCH,
Permission::DATA_IMPORT_BATCH,
]
.into_iter()
.collect()
}
fn account_viewer_permissions() -> HashSet<&'static str> {
[Permission::ACCOUNT_READ_DETAILS, Permission::DATA_READ]
.into_iter()
.collect()
}
}
// Global Roles (Starting with 1)
pub const DEFAULT_ADMIN_ROLE_ID: u64 = 100_000_000_000_000; // System Admin
pub const DEFAULT_MANAGER_ROLE_ID: u64 = 100_100_000_000_000; // System Manager
pub const DEFAULT_MEMBER_ROLE_ID: u64 = 100_200_000_000_000; // Regular Member (system:access)
// Account-specific Roles (Starting with 2)
pub const DEFAULT_ACCOUNT_MANAGER_ROLE_ID: u64 = 200_100_000_000_000;
pub const DEFAULT_ACCOUNT_VIEWER_ROLE_ID: u64 = 200_200_000_000_000;
fn is_builtin(id: u64) -> bool {
matches!(
id,
DEFAULT_ADMIN_ROLE_ID
| DEFAULT_MANAGER_ROLE_ID
| DEFAULT_MEMBER_ROLE_ID
| DEFAULT_ACCOUNT_MANAGER_ROLE_ID
| DEFAULT_ACCOUNT_VIEWER_ROLE_ID
)
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
pub enum RoleType {
#[default]
Global,
Account,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 9, version = 1)]
#[native_db]
pub struct UserRole {
#[primary_key]
pub id: u64,
pub name: String,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
pub is_builtin: bool,
pub created_at: i64,
pub role_type: RoleType,
pub updated_at: i64,
}
impl UserRole {
pub async fn ensure_default_roles_exists() -> BichonResult<()> {
let builtin_roles = vec![
(BuiltinRole::Admin, DEFAULT_ADMIN_ROLE_ID, RoleType::Global),
(
BuiltinRole::Manager,
DEFAULT_MANAGER_ROLE_ID,
RoleType::Global,
),
(
BuiltinRole::Member,
DEFAULT_MEMBER_ROLE_ID,
RoleType::Global,
),
(
BuiltinRole::AccountManager,
DEFAULT_ACCOUNT_MANAGER_ROLE_ID,
RoleType::Account,
),
(
BuiltinRole::AccountViewer,
DEFAULT_ACCOUNT_VIEWER_ROLE_ID,
RoleType::Account,
),
];
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
for (role, role_id, role_type) in builtin_roles {
let exists = rw
.get()
.primary::<UserRole>(role_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.is_some();
if !exists {
let permissions: BTreeSet<String> = role
.get_permissions()
.into_iter()
.map(|s| s.to_string())
.collect();
rw.insert(UserRole {
id: role_id,
name: role.to_string(),
description: Some(role.description().to_string()),
permissions,
created_at: now,
updated_at: now,
is_builtin: true,
role_type,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(())
}
pub async fn list_all() -> BichonResult<Vec<UserRole>> {
list_all_impl(DB_MANAGER.meta_db()).await
}
pub async fn find(role_id: u64) -> BichonResult<Option<UserRole>> {
async_find_impl(DB_MANAGER.meta_db(), role_id).await
}
pub async fn create(request: RoleCreateRequest) -> BichonResult<UserRole> {
let _ = &request.validate().await?;
let now = utc_now!();
let new_role = UserRole {
id: id!(64),
name: request.name,
description: request.description,
permissions: request.permissions,
created_at: now,
updated_at: now,
is_builtin: false,
role_type: request.role_type,
};
insert_impl(DB_MANAGER.meta_db(), new_role.clone()).await?;
Ok(new_role)
}
pub async fn update(id: u64, request: RoleUpdateRequest) -> BichonResult<()> {
if is_builtin(id) && request.permissions.is_some() {
return Err(raise_error!(
"The permissions of a builtin role are immutable. Please create a custom role instead.".into(),
ErrorCode::Forbidden
));
}
let _ = &request.validate().await?;
if let Some(permissions) = &request.permissions {
let role = Self::find(id).await?.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
)
})?;
Permission::validate_role_permissions(&role.role_type, permissions)?;
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(name) = request.name {
updated.name = name;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(permissions) = request.permissions {
updated.permissions = permissions;
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
pub async fn delete(id: u64) -> BichonResult<()> {
if is_builtin(id) {
return Err(raise_error!(
format!("Cannot delete a default system role (ID: {}).", id),
ErrorCode::InvalidParameter
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole '{}' not found during deletion process.", id),
ErrorCode::ResourceNotFound
)
})
})
.await
}
}
+54
View File
@@ -0,0 +1,54 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeMap;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::users::acl::AccessControl;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct UserView {
pub id: u64,
pub username: String,
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub account_roles_summary: BTreeMap<u64, String>,
pub account_permissions: BTreeMap<u64, Vec<String>>,
pub description: Option<String>,
/// Global Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub global_roles_names: Vec<String>,
pub global_permissions: Vec<String>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}
+19 -3
View File
@@ -16,18 +16,34 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use base64::{engine::general_purpose, Engine as _};
use ring::aead::{Aad, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, AES_256_GCM};
use ring::pbkdf2::{self, derive};
use ring::rand::{SecureRandom, SystemRandom};
use std::fs;
use std::num::NonZeroU32;
use std::sync::LazyLock;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::settings::cli::SETTINGS;
use crate::raise_error;
static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
if let Some(file_path) = &SETTINGS.bichon_encrypt_password_file {
return fs::read_to_string(file_path)
.expect("failed to read the file with the encrypt password")
.trim()
.to_string();
}
if let Some(p) = &SETTINGS.bichon_encrypt_password {
return p.clone();
}
panic!("Neither encrypt_password nor encrypt_password_file is set. This should have been validated by SETTINGS.");
});
struct SingleNonceSequence([u8; 12]);
impl SingleNonceSequence {
@@ -43,12 +59,12 @@ impl NonceSequence for SingleNonceSequence {
}
pub fn encrypt_string(plaintext: &str) -> BichonResult<String> {
internal_encrypt_string(&SETTINGS.bichon_encrypt_password, plaintext)
internal_encrypt_string(&ENCRYPT_PASSWORD, plaintext)
.map_err(|_| raise_error!("Failed to encrypt string.".into(), ErrorCode::InternalError))
}
pub fn decrypt_string(data: &str) -> BichonResult<String> {
internal_decrypt_string(&SETTINGS.bichon_encrypt_password, data).map_err(|_| {
internal_decrypt_string(&ENCRYPT_PASSWORD, data).map_err(|_| {
raise_error!(
"Decryption failed, likely due to incorrect encryption key or corrupted data".into(),
ErrorCode::InternalError
+25 -1
View File
@@ -16,9 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{fs, io, path::PathBuf};
use crate::modules::error::BichonResult;
use base64::engine::general_purpose::STANDARD;
use base64::{engine::general_purpose, Engine};
use rand::{rng, Rng};
@@ -310,3 +311,26 @@ pub fn get_total_size(path: &PathBuf) -> io::Result<u64> {
Ok(total_size)
}
const MAX_AVATAR_BYTES: usize = 128 * 1024;
pub fn decode_avatar_bytes(base64_str: &str) -> BichonResult<Vec<u8>> {
let bytes = STANDARD.decode(base64_str).map_err(|e| {
raise_error!(
format!("Invalid avatar base64 encoding: {}", e),
ErrorCode::InvalidParameter
)
})?;
if bytes.len() > MAX_AVATAR_BYTES {
return Err(raise_error!(
format!(
"Avatar image exceeds maximum size ({} KB).",
MAX_AVATAR_BYTES / 1024
),
ErrorCode::InvalidParameter
));
}
Ok(bytes)
}
+11 -12
View File
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use dashmap::DashMap;
use governor::{
clock::{QuantaClock, QuantaInstant},
@@ -30,14 +29,14 @@ use std::{
time::Duration,
};
use crate::modules::token::RateLimit;
use crate::modules::users::acl::RateLimit;
pub static RATE_LIMITER_MANAGER: LazyLock<TokenRateLimiter> = LazyLock::new(TokenRateLimiter::new);
pub static RATE_LIMITER_MANAGER: LazyLock<UserRateLimiter> = LazyLock::new(UserRateLimiter::new);
pub struct TokenRateLimiter {
pub struct UserRateLimiter {
limiters: Arc<
DashMap<
String,
u64,
(
Arc<RateLimiter<NotKeyed, InMemoryState, QuantaClock, NoOpMiddleware>>,
RateLimit,
@@ -46,29 +45,29 @@ pub struct TokenRateLimiter {
>,
}
impl TokenRateLimiter {
impl UserRateLimiter {
pub fn new() -> Self {
TokenRateLimiter {
UserRateLimiter {
limiters: Arc::new(DashMap::new()),
}
}
pub async fn check(
&self,
token: &str,
user_id: u64,
limit: RateLimit,
) -> Result<(), NotUntil<QuantaInstant>> {
let limiter = self.get_or_update_limiter(token, limit).await;
let limiter = self.get_or_update_limiter(user_id, limit).await;
limiter.check()
}
async fn get_or_update_limiter(
&self,
token: &str,
user_id: u64,
limit: RateLimit,
) -> Arc<RateLimiter<NotKeyed, InMemoryState, QuantaClock, NoOpMiddleware>> {
self.limiters
.entry(token.to_string())
.entry(user_id)
.and_modify(|(existing_limiter, current_limit)| {
if current_limit.interval != limit.interval || current_limit.quota != limit.quota {
let quota = Quota::with_period(Duration::from_secs(limit.interval))
@@ -100,4 +99,4 @@ impl TokenRateLimiter {
.0
.clone()
}
}
}
+5 -2
View File
@@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
"registries": {
"@reui": "https://reui.io/r/{name}.json"
}
}
+2 -1
View File
@@ -58,9 +58,10 @@
"i18next": "^25.6.3",
"js-cookie": "^3.0.5",
"lucide-react": "^0.468.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-ace": "^13.0.0",
"react-day-picker": "8.10.1",
"react-day-picker": "9.13.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.0",
"react-i18next": "^16.3.5",
+1775 -50
View File
File diff suppressed because it is too large Load Diff
-64
View File
@@ -1,64 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import axiosInstance from "@/api/axiosInstance";
import { AccessToken } from "@/features/access-tokens/data/schema";
export const login = async (password: string) => {
const response = await axiosInstance.post(`/api/login`, password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const reset_root_token = async () => {
const response = await axiosInstance.post("/api/v1/reset-root-token");
return response.data;
};
export const reset_root_password = async (password: string) => {
const response = await axiosInstance.post("/api/v1/reset-root-password", password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const list_access_tokens = async () => {
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
return response.data;
};
export const create_access_token = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/access-token", data);
return response.data;
}
export const update_access_token = async (token: string, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
return response.data;
}
export const delete_access_token = async (token: string) => {
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
return response.data;
}
+58 -1
View File
@@ -18,7 +18,6 @@
import axiosInstance from "@/api/axiosInstance";
import { AccountModel } from "@/features/accounts/data/schema";
import { PaginatedResponse } from "..";
export interface MinimalAccount {
@@ -56,6 +55,59 @@ export interface MailboxBatchProgress {
current_batch: number;
}
type Encryption = 'Ssl' | 'StartTls' | 'None';
type AuthType = 'Password' | 'OAuth2';
type Unit = 'Days' | 'Months' | 'Years';
type AccountType = 'IMAP' | 'NoSync';
// Interface definitions
interface AuthConfig {
auth_type: AuthType;
password?: string;
}
export interface ImapConfig {
host: string;
port: number; // integer, 0-65535
encryption: Encryption;
auth: AuthConfig;
use_proxy?: number;
}
interface RelativeDate {
unit: Unit;
value: number; // integer, minimum 1
}
interface DateSelection {
fixed?: string; // format: "YYYY-MM-DD"
relative?: RelativeDate;
}
export interface AccountModel {
id: number;
account_type: AccountType;
imap?: ImapConfig;
enabled: boolean;
name?: string,
email: string;
capabilities?: string[];
date_since?: DateSelection;
date_before?: RelativeDate;
folder_limit?: number,
sync_folders: string[];
sync_interval_min?: number;
sync_batch_size?: number;
created_by: number;
created_user_name: string;
created_user_email: string;
created_at: number;
updated_at: number;
use_proxy?: number
use_dangerous: boolean
}
export const account_state = async (account_id: number) => {
const response = await axiosInstance.get<AccountRunningState>(`/api/v1/account-state/${account_id}`);
return response.data;
@@ -103,3 +155,8 @@ export const autoconfig = async (email: string) => {
const response = await axiosInstance.get<AutoConfigResult>(`/api/v1/autoconfig/${email}`);
return response.data;
};
export const access_assign = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/accounts/access/assignments", data);
return response.data;
};
+4 -4
View File
@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { getAccessToken } from "@/stores/authStore";
import { getToken } from "@/stores/authStore";
import axios from "axios";
// Create an Axios instance
@@ -36,9 +36,9 @@ const axiosInstance = axios.create({
// Add a request interceptor to include the access token in headers
axiosInstance.interceptors.request.use(
(config) => {
const accessToken = getAccessToken(); // Retrieve access token from localStorage
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
const stored = getToken(); // Retrieve access token from localStorage
if (stored) {
config.headers.Authorization = `Bearer ${stored.accessToken}`;
}
return config;
},
+2
View File
@@ -30,6 +30,8 @@ export interface EmailEnvelope {
id: number;
message_id: string;
account_id: number;
account_email?: string;
mailbox_name?: string;
uid: number;
subject: string;
text: string;
+12 -3
View File
@@ -48,7 +48,7 @@ export const get_thread_messages = async (accountId: number, thread_id: number,
}
export const download_attachment = async (accountId: number, id: number, attachmentFileName: string) => {
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}?id=${id}&name=${attachmentFileName}`, { responseType: 'blob' });
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}/${id}?name=${attachmentFileName}`, { responseType: 'blob' });
const blob = new Blob([response.data]);
saveAs(blob, attachmentFileName);
};
@@ -83,7 +83,7 @@ export const getContent = (messageContent: MessageContentResponse): string | nul
};
export const load_message = async (accountId: number, id: number) => {
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}?id=${id}`);
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}/${id}`);
return response.data;
};
@@ -93,7 +93,16 @@ export const delete_messages = async (payload: Record<string, number[]>) => {
};
export const download_message = async (accountId: number, id: number) => {
const response = await axiosInstance.get(`/api/v1/download-message/${accountId}?id=${id}`, { responseType: 'blob' });
const response = await axiosInstance.get(`/api/v1/download-message/${accountId}/${id}`, { responseType: 'blob' });
const blob = new Blob([response.data]);
saveAs(blob, `${id}.eml`);
};
export const restore_message = async (accountId: number, messageIds: number[]) => {
const response = await axiosInstance.post(`/api/v1/restore-messages/${accountId}`, {
message_ids: messageIds,
});
return response.data;
};
+34 -1
View File
@@ -18,7 +18,6 @@
import axiosInstance from "@/api/axiosInstance";
import { Proxy } from "@/features/settings/proxy/data/schema";
export interface Release {
tag_name: string;
@@ -73,6 +72,34 @@ export interface LargestEmail {
size_bytes: number; // Email size in bytes
}
export interface Proxy {
id: number;
url: string;
created_at: number;
updated_at: number;
}
export type ServerConfigurations = {
bichon_log_level: string
bichon_http_port: number
bichon_bind_ip?: string | null
bichon_public_url: string
bichon_cors_origins?: string[] | null
bichon_cors_max_age: number
bichon_ansi_logs: boolean
bichon_log_to_file: boolean
bichon_json_logs: boolean
bichon_max_server_log_files: number
bichon_encrypt_password_set: boolean
bichon_webui_token_expiration_hours: number
bichon_root_dir: string
bichon_metadata_cache_size?: number | null
bichon_envelope_cache_size?: number | null
bichon_enable_rest_https: boolean
bichon_http_compression_enabled: boolean
bichon_sync_concurrency?: number | null
}
export const get_dashboard_stats = async () => {
const response = await axiosInstance.get<DashboardStats>(`/api/v1/dashboard-stats`);
return response.data;
@@ -104,4 +131,10 @@ export const add_proxy = async (url: string) => {
},
});
return response.data;
};
export const get_system_configurations = async () => {
const response = await axiosInstance.get<ServerConfigurations>(`/api/v1/system-configurations`);
return response.data;
};
+208
View File
@@ -0,0 +1,208 @@
import axiosInstance from "@/api/axiosInstance";
export type RoleType = 'Global' | 'Account';
export interface UserRole {
id: number;
name: string;
description?: string | null;
permissions: string[];
is_builtin: boolean;
role_type: RoleType;
created_at: number;
updated_at: number;
}
export function getPermissions(t: (key: string) => string) {
return [
// 1. Global Management
{ label: t('permission.system.access'), value: 'system:access' },
{ label: t('permission.system.root'), value: 'system:root' },
{ label: t('permission.user.manage'), value: 'user:manage' },
{ label: t('permission.user.view'), value: 'user:view' },
{ label: t('permission.token.manage'), value: 'token:manage' },
{ label: t('permission.account.create'), value: 'account:create' },
// 2. Global "ALL" Scoped (Admin)
{ label: t('permission.account.manage_all'), value: 'account:manage:all' },
{ label: t('permission.data.read_all'), value: 'data:read:all' },
{ label: t('permission.data.manage_all'), value: 'data:manage:all' },
{ label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' },
{ label: t('permission.data.delete_all'), value: 'data:delete:all' },
{ label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' },
// 3. Scoped / Limited
{ label: t('permission.account.manage'), value: 'account:manage' },
{ label: t('permission.account.read_details'), value: 'account:read_details' },
{ label: t('permission.data.read'), value: 'data:read' },
{ label: t('permission.data.manage'), value: 'data:manage' },
{ label: t('permission.data.raw_download'), value: 'data:raw:download' },
{ label: t('permission.data.delete'), value: 'data:delete' },
{ label: t('permission.data.export_batch'), value: 'data:export:batch' },
{ label: t('permission.data.import_batch'), value: 'data:import:batch' },
]
}
export interface RateLimit {
quota: number;
interval: number;
}
export interface AccessControl {
ip_whitelist?: string[];
rate_limit?: RateLimit;
}
export type TokenType = "WebUI" | "Api";
export interface AccessToken {
user_id: number;
user_name: string,
user_email: string,
token: string;
created_at: number;
updated_at: number;
name?: string;
last_access_at: number;
expire_at?: number | null;
token_type: TokenType;
}
export interface User {
id: number;
username: string;
email: string;
password?: string | null;
description?: string | null;
global_roles: number[];
global_roles_names: string[];
avatar?: string;
acl?: AccessControl;
account_access_map: Record<number, number>;
account_roles_summary: Record<number, string>;
global_permissions: string[]
account_permissions: Record<number, string[]>
created_at: number;
updated_at: number;
}
type Theme = 'dark' | 'light'
export interface LoginResult {
success: boolean;
error_message?: string | null;
access_token?: string | null;
theme?: Theme,
language?: string,
}
export interface MinimalUser {
id: number;
username: string;
email: string;
}
export const login = async (data: Record<string, any>) => {
const response = await axiosInstance.post<LoginResult>(`/api/login`, data);
return response.data;
};
export const reset_admin_token = async () => {
const response = await axiosInstance.post("/api/v1/reset-admin-token");
return response.data;
};
export const reset_admin_password = async (password: string) => {
const response = await axiosInstance.post("/api/v1/reset-admin-password", password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const list_access_tokens = async () => {
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
return response.data;
};
export const create_access_token = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/access-token", data);
return response.data;
}
export const update_access_token = async (token: string, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
return response.data;
}
export const remove_access_token = async (token: string) => {
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
return response.data;
}
export const list_roles = async () => {
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-roles");
return response.data;
};
export const remove_role = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/roles/${id}`);
return response.data;
};
export const create_role = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/roles", data);
return response.data;
};
export const update_role = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/roles/${id}`, data);
return response.data;
};
export const list_users = async () => {
const response = await axiosInstance.get<User[]>("/api/v1/list-users");
return response.data;
};
export const list_minimal_users = async () => {
const response = await axiosInstance.get<MinimalUser[]>("/api/v1/minimal-user-list");
return response.data;
};
export const remove_user = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/users/${id}`);
return response.data;
};
export const create_user = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/users", data);
return response.data;
};
export const update_user = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/users/${id}`, data);
return response.data;
};
export const get_user_tokens = async (id: number) => {
const response = await axiosInstance.get<AccessToken[]>(`/api/v1/user-tokens/${id}`);
return response.data;
};
export const get_current_user = async () => {
const response = await axiosInstance.get<User>("/api/v1/current-user");
return response.data;
};
+3 -1
View File
@@ -22,10 +22,11 @@ import { FixedHeader } from "./layout/fixed-header";
import { Main } from "./layout/main";
import Logo from '@/assets/logo.svg'
import { useTranslation } from 'react-i18next'
import { Separator } from "./ui/separator";
export default function APIDocs() {
const { t } = useTranslation()
const docsOptions = [
{ name: t('apiDocs.swaggerUI'), path: "/api-docs/swagger" },
{ name: t('apiDocs.reDoc'), path: "/api-docs/redoc" },
@@ -51,6 +52,7 @@ export default function APIDocs() {
</p>
</div>
</div>
<Separator className='mt-2 mb-4 lg:mt-3 lg:mb-6' />
<div className='-mx-4 flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-6 p-4'>
<div className="grid w-full gap-4 sm:grid-cols-1 md:grid-cols-2 xl:max-w-4xl">
+2
View File
@@ -29,6 +29,7 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
interface ConfirmDialogProps {
open: boolean
@@ -79,6 +80,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
onClick={handleConfirm}
disabled={disabled || isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{confirmText ?? t('dialogs.continue')}
</Button>
</AlertDialogFooter>
+8 -1
View File
@@ -7,6 +7,9 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import i18n from '@/i18n'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
type DatePickerProps = {
selected: Date | undefined
@@ -19,6 +22,10 @@ export function DatePicker({
onSelect,
placeholder = 'Pick a date',
}: DatePickerProps) {
const currentLang = i18n.language.toLowerCase().replace('_', '-');
const dateLocale = dateFnsLocaleMap[currentLang] || enUS;
return (
<Popover>
<PopoverTrigger asChild>
@@ -28,7 +35,7 @@ export function DatePicker({
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
>
{selected ? (
format(selected, 'MMM d, yyyy')
format(selected, 'PPP', { locale: dateLocale })
) : (
<span>{placeholder}</span>
)}
+1
View File
@@ -39,6 +39,7 @@ const LANGUAGES = [
{ code: 'ko', label: '한국어' },
{ code: 'nl', label: 'Nederlands' },
{ code: 'no', label: 'Norsk' },
{ code: 'pl', label: 'Polski' },
{ code: 'pt', label: 'Português' },
{ code: 'ru', label: 'Русский' },
{ code: 'sv', label: 'Svenska' },
+16 -7
View File
@@ -20,16 +20,18 @@
import {
IconHelp,
IconLayoutDashboard,
IconLockAccess,
IconSettings
} from '@tabler/icons-react'
import { IdCard, Inbox, Mailbox, Search } from 'lucide-react'
import { IdCard, Inbox, Mailbox, Search, Users2 } from 'lucide-react'
import { type SidebarData } from '../types'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
export function useSidebarData(): SidebarData {
const { t } = useTranslation()
const { require_any_permission } = useCurrentUser()
return {
navGroups: [
{
@@ -69,11 +71,18 @@ export function useSidebarData(): SidebarData {
title: t('navigation.oauth2'),
url: '/oauth2',
icon: IdCard,
},
visible: require_any_permission(['system:root', 'account:create']),
}
]
},
{
title: t('navigation.users'),
items: [
{
title: t('navigation.accessTokens'),
url: '/access-tokens',
icon: IconLockAccess,
title: t('navigation.users'),
url: '/users',
icon: Users2,
visible: require_any_permission(['system:root', 'user:manage']),
}
]
},
+20 -26
View File
@@ -1,22 +1,3 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ReactNode } from 'react'
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
@@ -50,11 +31,16 @@ import { NavCollapsible, NavItem, NavLink, type NavGroup } from './types'
export function NavGroup({ title, items }: NavGroup) {
const { state } = useSidebar()
const href = useLocation({ select: (location) => location.href })
const visibleItems = items.filter(item => item.visible !== false)
if (visibleItems.length === 0) return null
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
{visibleItems.map((item) => {
const key = `${item.title}-${item.url}`
if (!item.items)
@@ -103,6 +89,10 @@ const SidebarMenuCollapsible = ({
href: string
}) => {
const { setOpenMobile } = useSidebar()
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
if (visibleSubItems.length === 0) return null
return (
<Collapsible
asChild
@@ -120,7 +110,7 @@ const SidebarMenuCollapsible = ({
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
{visibleSubItems.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
@@ -148,6 +138,10 @@ const SidebarMenuCollapsedDropdown = ({
item: NavCollapsible
href: string
}) => {
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
if (visibleSubItems.length === 0) return null
return (
<SidebarMenuItem>
<DropdownMenu>
@@ -167,7 +161,7 @@ const SidebarMenuCollapsedDropdown = ({
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
{visibleSubItems.map((sub) => (
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
<Link
to={sub.url}
@@ -189,11 +183,11 @@ const SidebarMenuCollapsedDropdown = ({
function checkIsActive(href: string, item: NavItem, mainNav = false) {
return (
href === item.url || // /endpint?search=param
href.split('?')[0] === item.url || // endpoint
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
href === item.url ||
href.split('?')[0] === item.url ||
!!item?.items?.filter((i) => i.url === href).length ||
(mainNav &&
href.split('/')[1] !== '' &&
href.split('/')[1] === item?.url?.split('/')[1])
)
}
}
+2 -1
View File
@@ -23,6 +23,7 @@ interface BaseNavItem {
title: string
badge?: string
icon?: React.ElementType
visible?: boolean
}
type NavLink = BaseNavItem & {
@@ -31,7 +32,7 @@ type NavLink = BaseNavItem & {
}
type NavCollapsible = BaseNavItem & {
items: (BaseNavItem & { url: LinkProps['to'] })[]
items: (BaseNavItem & { url: LinkProps['to']; visible?: boolean })[]
url?: never
}
+38
View File
@@ -20,6 +20,8 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Button } from '@/components/ui/button'
import {
@@ -30,6 +32,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
import { showNumbers } from '@/lib/utils'
interface PaginationProps {
totalItems: number
@@ -66,6 +69,9 @@ export function EnvelopeListPagination({
setPageIndex(newPageIndex)
}
const currentPage = pageIndex + 1;
const pageNumbers = showNumbers(currentPage, pageCount)
return (
<div className='flex items-center justify-between space-x-2 overflow-auto px-2'>
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
@@ -94,6 +100,14 @@ export function EnvelopeListPagination({
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => setPageIndex(0)}
disabled={pageIndex === 0}
>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
@@ -103,6 +117,22 @@ export function EnvelopeListPagination({
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
{pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageNumber === '...' ? (
<span className='px-1 text-sm text-muted-foreground'>...</span>
) : (
<Button
variant={currentPage === pageNumber ? 'default' : 'outline'}
className='h-8 min-w-8 px-2'
onClick={() => setPageIndex((pageNumber as number) - 1)}
>
{pageNumber}
</Button>
)}
</div>
))}
<Button
variant='outline'
className='h-8 w-8 p-0'
@@ -112,6 +142,14 @@ export function EnvelopeListPagination({
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => setPageIndex(pageCount - 1)}
disabled={!hasNextPage()}
>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
+49 -22
View File
@@ -22,48 +22,75 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { LogoutConfirmDialog } from '@/features/auth/sign-in/components/logout';
import { resetAccessToken } from '@/stores/authStore';
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useCurrentUser } from '@/hooks/use-current-user';
import useDialogState from '@/hooks/use-dialog-state';
import { useMemo } from 'react';
import { SignOutDialog } from './sign-out-dialog';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
export function ProfileDropdown() {
const navigate = useNavigate()
const [open, setOpen] = useDialogState()
const { t } = useTranslation()
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
const handleLogout = () => {
resetAccessToken()
navigate({ to: '/sign-in' })
}
const { data: user } = useCurrentUser()
const avatarSrc = useMemo(() => {
const base64 = user?.avatar;
if (!base64 || base64.length === 0) return null;
return `data:image/png;base64,${base64}`;
}, [user]);
const fallbackName = user?.username ? user.username.charAt(0).toUpperCase() : 'U';
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
<Avatar className='h-8 w-8'>
<AvatarFallback className='text-xs'>root</AvatarFallback>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
{avatarSrc ? (
<img src={avatarSrc} alt={t('profile.avatar_alt')} className="h-full w-full object-cover" />
) : (
<AvatarFallback className="text-xs">{fallbackName}</AvatarFallback>
)}
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<DropdownMenuItem onClick={() => setIsLogoutDialogOpen(true)}>
{t('auth.logout')}
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
<DropdownMenuLabel className='font-normal'>
<div className='flex flex-col gap-1.5'>
<p className='text-sm leading-none font-medium'>{user?.username}</p>
<p className='text-muted-foreground text-xs leading-none'>
{user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to='/settings/profile'>{t('profile.menu.profile')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to='/settings'>{t('profile.menu.settings')}</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setOpen(true)}>
{t('profile.menu.sign_out')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LogoutConfirmDialog
open={isLogoutDialogOpen}
onOpenChange={setIsLogoutDialogOpen}
handleConfirm={handleLogout}
/>
<SignOutDialog open={!!open} onOpenChange={setOpen} />
</>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { useNavigate, useLocation } from '@tanstack/react-router'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetToken } from '@/stores/authStore'
import { useTranslation } from 'react-i18next'
interface SignOutDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation()
const handleSignOut = () => {
resetToken()
const currentPath = location.href
navigate({
to: '/sign-in',
search: { redirect: currentPath },
replace: true,
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={t('sign_out.title', 'Sign out')}
desc={t(
'sign_out.desc',
'Are you sure you want to sign out? You will need to sign in again to access your account.'
)}
confirmText={t('sign_out.confirm', 'Sign out')}
destructive
handleConfirm={handleSignOut}
className="sm:max-w-sm"
/>
)
}
+1 -1
View File
@@ -55,4 +55,4 @@ const AlertDescription = React.forwardRef<
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription }
+186 -45
View File
@@ -1,69 +1,210 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: CalendarProps) {
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md'
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal aria-selected:opacity-100'
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
day_range_start: 'day-range-start',
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className='h-4 w-4' />,
IconRight: () => <ChevronRight className='h-4 w-4' />,
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
Calendar.displayName = 'Calendar'
export { Calendar }
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+1 -2
View File
@@ -273,8 +273,7 @@ export function VirtualizedSelect({
.filter(Boolean);
if (selectedLabels.length === 0) return placeholder;
if (selectedLabels.length <= 3) return selectedLabels.join(', ');
return `${selectedLabels[0]}, ${selectedLabels[1]} +${selectedLabels.length - 2} more`;
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
};
return (
@@ -1,50 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccountInfo } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
export const getColumns = (t: (key: string) => string): ColumnDef<AccountInfo>[] => [
{
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accessTokens.accountId')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.id}</LongText>
),
meta: { className: 'w-80' },
enableHiding: false,
enableSorting: false
},
{
accessorKey: 'email',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.email')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.getValue('email')}</LongText>
),
meta: { className: 'w-80' },
enableHiding: true,
enableSorting: false
},
]
@@ -1,69 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccessToken } from '../data/schema'
import { Button } from '@/components/ui/button'
import { AccountsDetailTable } from './accounts-detail-table'
import { getColumns } from './accounts-detail-columns'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const columns = getColumns(t)
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-4xl'>
<DialogHeader className='text-left'>
<DialogTitle>{t('settings.accounts')}</DialogTitle>
<DialogDescription>
{t('accessTokens.theListOfAccountsThatCanBeQueried')}
</DialogDescription>
</DialogHeader>
<div className="h-[33rem] overflow-x-auto overflow-y-auto">
<AccountsDetailTable data={currentRow.accounts} columns={columns} />
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,99 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccessToken } from '../data/schema'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-xl'>
<DialogHeader className='text-left'>
<DialogTitle>{t('settings.acl')}</DialogTitle>
<DialogDescription>
{t('accessTokens.aclRulesForAccessTokens')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[33rem] w-full pr-4 -mr-4 py-1'>
<div className="space-y-4">
{/* IP Whitelist */}
<div className="grid w-full items-center">
<Label className="mb-2">IP Whitelist</Label>
<Textarea
className="col-span-5 max-h-[240px] min-h-[300px]"
value={currentRow.acl?.ip_whitelist?.join('\n')}
/>
</div>
{/* Quota */}
<div className="grid w-full items-center">
<Label className="mb-2">Quota</Label>
<Input
type="number"
value={currentRow.acl?.rate_limit?.quota}
className="col-span-5"
/>
</div>
{/* Interval (seconds) */}
<div className="grid w-full items-center">
<Label className="mb-2">Interval (seconds)</Label>
<Input
type="number"
className="col-span-5"
value={currentRow.acl?.rate_limit?.interval}
/>
</div>
</div>
</ScrollArea>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,404 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from '@/hooks/use-toast'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
// import { MultiSelect } from '@/components/multi-select'
import { Textarea } from '@/components/ui/textarea'
import { AccessToken } from '../data/schema'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { create_access_token, update_access_token } from '@/api/access-tokens/api'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { VirtualizedSelect } from '@/components/virtualized-select'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
const isValidIP = (ip: string) => {
const ipv4Regex = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/;
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
};
const RateLimitBaseSchema = z.object({
quota: z.optional(z.number()),
interval: z.optional(z.number()),
});
const AccessControlBaseSchema = z.object({
ip_whitelist: z.string().optional(),
rate_limit: z.optional(RateLimitBaseSchema),
});
const AccessTokenBaseSchema = z.object({
accounts: z.array(z.number()),
description: z.optional(z.string()),
acl: z.optional(AccessControlBaseSchema),
});
export type AccessTokenForm = z.infer<typeof AccessTokenBaseSchema>;
const getRateLimitSchema = (t: (key: string) => string) => z.object({
quota: z.optional(z.number().int().positive({ message: t('accessTokens.quotaMustBeAPositiveInteger') })),
interval: z.optional(z.number().int().positive({ message: t('accessTokens.intervalMustBeAPositiveInteger') })),
});
const getAccessControlSchema = (t: (key: string) => string) => AccessControlBaseSchema.extend({
rate_limit: getRateLimitSchema(t).optional(),
}).transform((data) => {
if (data.ip_whitelist) {
const ips = data.ip_whitelist
.split('\n')
.map((ip) => ip.trim())
.filter((ip) => ip !== '');
return {
...data,
ip_whitelist: ips.join('\n'),
};
}
return data;
}).refine(
(data) => {
if (data.ip_whitelist) {
const ips = data.ip_whitelist.split('\n');
const invalidIPs = ips.filter((ip) => !isValidIP(ip));
return invalidIPs.length === 0;
}
return true;
},
{
message: t('accessTokens.invalidIpAddressesFound'),
path: ['ip_whitelist'],
}
).transform((data) => {
if (data.rate_limit && !data.rate_limit.interval && !data.rate_limit.quota) {
return {
...data,
rate_limit: undefined,
};
}
return data;
})
.transform((data) => {
if (!data.ip_whitelist && !data.rate_limit) {
return undefined;
}
return data;
});
const getAccessTokenFormSchema = (t: (key: string) => string) => AccessTokenBaseSchema.extend({
accounts: z
.array(z.number())
.min(1, { message: t('accessTokens.atLeastOneAccountIsRequired') }),
description: z
.optional(z.string().max(255, { message: t('accessTokens.descriptionMustNotExceed255Characters') })),
acl: z.optional(getAccessControlSchema(t)),
});
interface Props {
currentRow?: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
const defaultValues = {
accounts: [],
description: undefined,
access_scopes: [],
acl: undefined,
};
export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow
const queryClient = useQueryClient();
const form = useForm<AccessTokenForm>({
resolver: zodResolver(getAccessTokenFormSchema(t)),
defaultValues: isEdit
? {
accounts: currentRow.accounts.map(value => value.id),
description: currentRow.description ?? undefined,
acl: currentRow.acl
? {
ip_whitelist: currentRow.acl.ip_whitelist
? currentRow.acl.ip_whitelist.join('\n')
: undefined,
rate_limit: currentRow.acl.rate_limit ? currentRow.acl.rate_limit : undefined
}
: undefined,
}
: defaultValues,
});
const createMutation = useMutation({
mutationFn: create_access_token,
onSuccess: handleSuccess,
onError: handleError
});
const updateMutation = useMutation({
mutationFn: (data: Record<string, any>) => update_access_token(currentRow?.token ?? '', data),
onSuccess: handleSuccess,
onError: handleError
})
function handleSuccess() {
toast({
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updated') : t('accessTokens.created')}`,
description: t('accessTokens.yourAccessTokenHasBeenSuccessfully', { action: isEdit ? t('accessTokens.updated').toLowerCase() : t('accessTokens.created').toLowerCase() }),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['access-tokens'] });
form.reset();
onOpenChange(false);
}
function handleError(error: AxiosError) {
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
t('accessTokens.updateOrCreationFailed', { action: isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed') });
toast({
variant: "destructive",
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed')}`,
description: errorMessage as string,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
const { accountsOptions, isLoading } = useMinimalAccountList();
const onSubmit = (values: AccessTokenForm) => {
const payload = {
accounts: values.accounts,
description: values.description,
acl: values.acl
? {
...values.acl,
ip_whitelist: values.acl.ip_whitelist
? (() => {
const ipSet = new Set(
values.acl.ip_whitelist
.split('\n')
.map(ip => ip.trim())
.filter(ip => ip !== ''),
);
return ipSet.size > 0 ? Array.from(ipSet) : undefined;
})()
: undefined,
}
: undefined,
};
if (isEdit) {
updateMutation.mutate(payload);
} else {
createMutation.mutate(payload);
}
}
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset()
onOpenChange(state)
}}
>
<DialogContent className='max-w-4xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? t('accessTokens.editToken') : t('accessTokens.addNew')}</DialogTitle>
<DialogDescription>
{isEdit ? t('accessTokens.updateTheAccessTokenHere') : t('accessTokens.createNewAccessTokenHere')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[28rem] w-full pr-4 -mr-4 py-1'>
<Form {...form}>
<form
id='token-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4 p-0.5'
>
<FormField
control={form.control}
name='accounts'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>{t('accessTokens.accounts')}:</FormLabel>
<FormControl>
<VirtualizedSelect
multiple
options={accountsOptions}
className='w-full'
isLoading={isLoading}
onSelectOption={(options) => {
const numberArray = options.map((v) => parseInt(v, 10));
return field.onChange(numberArray);
}}
value={field.value.map(String)}
placeholder={t('accessTokens.selectAccounts')}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t('accessTokens.selectMultipleAccountsForTheAccessToken')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="acl.ip_whitelist"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0">
<FormLabel className='mb-1'>{t('settings.acl')}:</FormLabel>
<FormControl>
<Textarea
placeholder={t('accessTokens.enterOneIpAddressPerLine')}
{...field}
className="max-h-[500px] min-h-[180px]"
/>
</FormControl>
<FormDescription>
{t('accessTokens.aListOfIpAddressesAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<FormField
control={form.control}
name="acl.rate_limit.quota"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>{t('accessTokens.quota')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accessTokens.enterQuota')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
{t('accessTokens.theMaximumNumberOfRequestsAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="acl.rate_limit.interval"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>{t('accessTokens.interval')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accessTokens.enterIntervalInSeconds')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
{t('accessTokens.theTimeWindowForTheRateLimit')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>{t('settings.description')}:</FormLabel>
<FormControl>
<Textarea
placeholder={t('accessTokens.describeThePurposeOfTheAccessToken')}
{...field}
className="max-h-[240px] min-h-[80px]"
/>
</FormControl>
<FormDescription>{t('oauth2.optional')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<DialogFooter>
<Button
type="submit"
form="token-form"
disabled={isEdit ? updateMutation.isPending : createMutation.isPending}
className="min-w-[100px] relative transition-all"
>
<span className="inline-flex items-center justify-center gap-2">
{(isEdit ? updateMutation.isPending : createMutation.isPending) && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
<span>
{isEdit
? updateMutation.isPending
? t('accessTokens.updating')
: t('accessTokens.saveChanges')
: createMutation.isPending
? t('accessTokens.creating')
: t('accessTokens.save')}
</span>
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,127 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccessToken } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format, formatDistanceToNow, Locale } from 'date-fns'
import { AccountCellAction } from './account-action'
import { AclCellAction } from './acl-action'
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
{
accessorKey: 'token',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.token')} />
),
cell: ({ row }) => {
return <LongText className='w-40'>{row.original.token}</LongText>
},
meta: { className: 'w-40' },
enableHiding: false,
enableSorting: false,
},
{
accessorKey: 'accounts',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.accounts')} />
),
cell: AccountCellAction,
meta: { className: 'w-10 text-center' },
filterFn: (row, columnId, filterValue) => {
const accounts = row.getValue(columnId) as { account_id: number; email: string }[];
if (!filterValue) return true;
return accounts.some(
(account) =>
`${account.account_id}`.includes(filterValue) ||
account.email.includes(filterValue)
);
},
},
{
id: 'acl',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.acl')} />
),
cell: AclCellAction,
meta: { className: 'w-8 text-center' },
enableSorting: false
},
{
accessorKey: 'description',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.description')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.description}</LongText>
),
meta: { className: 'w-80' },
enableHiding: true,
enableSorting: false
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
),
cell: ({ row }) => {
const created_at = row.original.created_at;
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
),
cell: ({ row }) => {
const updated_at = row.original.updated_at;
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'last_access_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.lastAccess')} />
),
cell: ({ row }) => {
const last_access_at = row.original.last_access_at;
if (last_access_at === 0) {
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
}
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
return <LongText className='max-w-40'>{result}</LongText>;
},
meta: { className: 'w-40' },
enableHiding: false,
},
{
id: 'actions',
cell: DataTableRowActions,
},
]
@@ -1,103 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
ChevronLeftIcon,
ChevronRightIcon,
} from '@radix-ui/react-icons'
import { Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected: boolean,
showPageSizeSelector: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
showPageSizeSelector = true
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation();
return (
<div className='flex items-center justify-between overflow-auto px-2'>
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{table.getFilteredRowModel().rows.length} {t("table.results")}
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 {t("table.rowsPerPage")}.
</div>}
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
{showPageSizeSelector && <div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>}
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
{t("table.page")} {table.getState().pagination.pageIndex + 1}{" "}
{t("table.of")} {table.getPageCount()}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}
-160
View File
@@ -1,160 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { Button } from '@/components/ui/button'
import { Main } from '@/components/layout/main'
import { TokensActionDialog } from './components/action-dialog'
import { getColumns } from './components/columns'
import { TokenDeleteDialog } from './components/delete-dialog'
import { AccessTokensTable } from './components/access-token-table'
import AccessTokensProvider, {
type AccessTokensDialogType,
} from './context'
import Logo from '@/assets/logo.svg'
import { Plus } from 'lucide-react'
import { AccessToken } from './data/schema'
import { AccountDetailDialog } from './components/accounts-detail-dialog'
import { AclDetailDialog } from './components/acl-detail-dialog'
import { useQuery } from '@tanstack/react-query'
import { list_access_tokens } from '@/api/access-tokens/api'
import { TableSkeleton } from '@/components/table-skeleton'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
export default function AccessTokens() {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
// Dialog states
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
const { data: accessTokens, isLoading } = useQuery({
queryKey: ['access-tokens'],
queryFn: list_access_tokens,
})
const columns = getColumns(t, locale)
return (
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
{/* ===== Top Heading ===== */}
<FixedHeader />
<Main>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
<p className="text-muted-foreground">
{t('accessTokens.description')}
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('common.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('accessTokens.noTokensDesc')}
</p>
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
</div>
</div>
)}
</div>
</Main>
<TokensActionDialog
key='token-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')}
/>
{currentRow && (
<>
<TokensActionDialog
key={`token-edit-${currentRow.token}`}
open={open === 'edit'}
onOpenChange={() => {
setOpen('edit')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<TokenDeleteDialog
key={`token-delete-${currentRow.token}`}
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<AccountDetailDialog
key={`accounts-detail-${currentRow.token}`}
currentRow={currentRow}
open={open === 'account-detail'}
onOpenChange={() => {
setOpen('account-detail')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}} />
<AclDetailDialog
key={`acl-detail-${currentRow.token}`}
currentRow={currentRow}
open={open === 'acl-detail'}
onOpenChange={() => {
setOpen('acl-detail')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}} />
</>
)}
</AccessTokensProvider>
)
}
@@ -0,0 +1,275 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { useToast } from '@/hooks/use-toast'
import { useRoles } from '@/hooks/use-roles'
import { useMinimalUsers } from '@/hooks/use-minimal-users'
import { access_assign, AccountModel } from '@/api/account/api'
interface Props {
currentRow: AccountModel
open: boolean
onOpenChange: (open: boolean) => void
}
export function AccountAccessAssignmentDialog({
currentRow,
open,
onOpenChange,
}: Props) {
const { t } = useTranslation()
const { toast } = useToast()
const queryClient = useQueryClient()
const { accountRoles, isLoading: isLoadingRoles } = useRoles()
const { users, isLoading: isLoadingUsers } = useMinimalUsers()
const [keyword, setKeyword] = React.useState('')
// 1. 定义校验 Schema (集成国际化错误提示)
const assignmentSchema = z.object({
account_ids: z.array(z.number()),
user_ids: z.array(z.number()).min(1, {
message: t('accounts.access_control.validation.user_required'),
}),
role_id: z.number({
required_error: t('accounts.access_control.validation.role_required'),
}),
})
type AssignmentFormValues = z.infer<typeof assignmentSchema>
const form = useForm<AssignmentFormValues>({
resolver: zodResolver(assignmentSchema),
defaultValues: {
account_ids: [currentRow.id],
user_ids: [],
role_id: undefined as any,
},
})
const filteredUsers = React.useMemo(() => {
if (!keyword.trim()) return users
const lowerKeyword = keyword.toLowerCase()
return users.filter(
(user) =>
user.username.toLowerCase().includes(lowerKeyword) ||
user.email.toLowerCase().includes(lowerKeyword)
)
}, [users, keyword])
const { mutate, isPending } = useMutation({
mutationFn: access_assign,
onSuccess: () => {
toast({
title: t('accounts.access_control.toast.success_title'),
description: t('accounts.access_control.toast.success_desc', { email: currentRow.email }),
})
queryClient.invalidateQueries({ queryKey: ['account-access-list'] })
onOpenChange(false)
},
onError: (error: any) => {
toast({
variant: 'destructive',
title: t('accounts.access_control.toast.failed_title'),
description: error.response?.data?.message || error.message,
})
},
})
const onSubmit = (data: AssignmentFormValues) => {
mutate(data)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-blue-600" />
{t('accounts.access_control.title')}
</DialogTitle>
<DialogDescription>
{t('accounts.access_control.description', { email: currentRow.email })}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="px-6 space-y-6">
<FormField
control={form.control}
name="role_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.access_control.role_label')}</FormLabel>
<Select
disabled={isLoadingRoles}
onValueChange={(value) => field.onChange(Number(value))}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingRoles
? t('accounts.access_control.role_loading')
: t('accounts.access_control.role_placeholder')
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{accountRoles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<FormLabel className="flex items-center gap-2">
<Users className="w-4 h-4" />
{t('accounts.access_control.user_label')}
</FormLabel>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('accounts.access_control.user_search_placeholder')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-10"
/>
</div>
<div className="border rounded-md">
<ScrollArea className="h-64">
{isLoadingUsers ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="p-3 space-y-1">
{filteredUsers.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
{t('accounts.access_control.user_empty')}
</div>
) : (
filteredUsers.map((user) => (
<FormField
key={user.id}
control={form.control}
name="user_ids"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-md hover:bg-accent/50 px-2 py-2 transition-colors">
<FormControl>
<Checkbox
checked={field.value?.includes(user.id) ?? false}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...(field.value ?? []), user.id])
} else {
field.onChange(
field.value?.filter((id: number) => id !== user.id) ?? []
)
}
}}
/>
</FormControl>
<label className="flex-1 cursor-pointer select-none space-y-1">
<div className="font-medium text-sm">{user.username}</div>
<div className="text-xs text-muted-foreground">
{user.email}
</div>
</label>
</FormItem>
)}
/>
))
)}
</div>
)}
</ScrollArea>
</div>
<FormMessage>{form.formState.errors.user_ids?.message}</FormMessage>
{form.watch('user_ids')?.length > 0 && (
<div className="text-sm text-muted-foreground">
{t('accounts.access_control.user_selected_count', { count: form.watch('user_ids').length })}
</div>
)}
</div>
</div>
<DialogFooter className="bg-muted/50 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('accounts.access_control.buttons.cancel')}
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('accounts.access_control.buttons.save')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -17,7 +17,6 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { AccountModel } from '../data/schema'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -25,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTranslation } from 'react-i18next'
import { AccountModel } from '@/api/account/api'
interface Props {
open: boolean
@@ -34,6 +34,30 @@ interface Props {
export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
const { t } = useTranslation()
const sinceText = (() => {
if (currentRow.date_since?.fixed) {
return currentRow.date_since.fixed;
}
if (currentRow.date_since?.relative?.value) {
return `${t('accounts.sinceRelativeValue', {
value: currentRow.date_since!.relative!.value,
unit: t(`accounts.${currentRow.date_since!.relative!.unit!.toLowerCase()}`)
})}`;
}
return t('accounts.syncAll');
})();
const hasSince = !!currentRow.date_since;
const hasBefore = !!currentRow.date_before?.value;
return (
<Dialog
open={open}
@@ -77,6 +101,10 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
<span className="text-muted-foreground">{t('accounts.incrementalSyncInterval')}:</span>
<span>{t('accounts.everyMinutes', { minutes: currentRow.sync_interval_min })}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.syncBatchSize')}:</span>
<span>{currentRow.sync_batch_size}</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground">{t('accounts.capabilities')}:</span>
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
@@ -84,14 +112,33 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
</code>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.dateSelection')}:</span>
<span>
{currentRow.date_since?.fixed
? t('accounts.since') + ' ' + currentRow.date_since.fixed
: currentRow.date_since?.relative
? t('accounts.recent') + ' ' + currentRow.date_since.relative.value + ' ' + currentRow.date_since.relative.unit
: t('accounts.notAvailable')}
</span>
<span className="text-muted-foreground">{t('accounts.syncScope')}:</span>
{hasSince && (
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
{t('accounts.sinceFixed')}:
</span>
<span className="text-sm">{sinceText}</span>
</div>
)}
{hasBefore && (
<div className="flex flex-col border-t pt-2">
<span className="text-xs text-muted-foreground">
{t('accounts.beforeRelative')}:
</span>
<span className="text-sm">
{t('accounts.beforeRelativeValue', {
value: currentRow.date_before!.value,
unit: t(`accounts.${currentRow.date_before!.unit!.toLowerCase()}`)
})}
</span>
</div>
)}
{!hasSince && !hasBefore && (
<span className="text-sm text-muted-foreground">
{t('accounts.syncAll')}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.folderLimit')}:</span>
@@ -100,8 +147,6 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
</div>
</CardContent>
</Card>
{/* Server Configuration Card */}
<Card>
<CardHeader>
<CardTitle>{t('accounts.serverConfiguration')}</CardTitle>
@@ -143,8 +188,6 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
</div>
</CardContent>
</Card>
{/* Sync Folders Card */}
<Card>
<CardHeader>
<CardTitle>{t('accounts.syncFoldersTitle')}</CardTitle>

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