mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44fc0e15de | ||
|
|
ef891b20c3 | ||
|
|
a6216c2ce6 | ||
|
|
1c58b516dd | ||
|
|
ae916574de | ||
|
|
14fb3368a3 | ||
|
|
f49929dd67 | ||
|
|
97143d55b8 | ||
|
|
e666f76d87 | ||
|
|
7fb6575f8d | ||
|
|
fb0be8c5d1 | ||
|
|
455e6b1a75 | ||
|
|
75cae51be9 | ||
|
|
62d956c7d6 | ||
|
|
c01872284e | ||
|
|
2887b5d16d | ||
|
|
558ea2f9b0 | ||
|
|
b07defa2d5 | ||
|
|
76ab16b55b | ||
|
|
06a126461b | ||
|
|
a02bb65ca0 | ||
|
|
1f57f372d3 | ||
|
|
b35493e4e1 | ||
|
|
16578fb8e2 | ||
|
|
6dd3f90ee0 | ||
|
|
6d11dcd33f | ||
|
|
e64c2467fd | ||
|
|
e8a15695d8 | ||
|
|
0f3ad83004 | ||
|
|
4af5176b65 | ||
|
|
1e2f526a07 | ||
|
|
97be76278e | ||
|
|
d4232789f9 | ||
|
|
9b83d5617e | ||
|
|
70db81dc03 | ||
|
|
6b18d7371d | ||
|
|
934e81c5f9 | ||
|
|
c05a8944ef | ||
|
|
eaff2ca70d | ||
|
|
000d144e70 | ||
|
|
20970b4fb6 | ||
|
|
57df3466e7 | ||
|
|
75d859abdf | ||
|
|
b0412b02f5 | ||
|
|
a1453f3cda | ||
|
|
9e656b1f91 | ||
|
|
afac192280 | ||
|
|
c00ffe8d11 | ||
|
|
c90a2d552d | ||
|
|
a4627a63cb | ||
|
|
684b9765fe | ||
|
|
b0ce7671a8 | ||
|
|
4b13bf14c4 | ||
|
|
0015192b4d | ||
|
|
2f3acdd759 | ||
|
|
0fc83b693e | ||
|
|
9ba56ca5bb | ||
|
|
8f7244ccb9 | ||
|
|
454950374f | ||
|
|
3a2b42f5c3 | ||
|
|
34ec3a7d5b | ||
|
|
82397ab0cd | ||
|
|
1cfc12324f | ||
|
|
dffdac3eb6 | ||
|
|
7d02e58e4e | ||
|
|
847cc6825a | ||
|
|
6ce1420714 | ||
|
|
9a72ce9154 | ||
|
|
9f713ef044 | ||
|
|
275cde180b | ||
|
|
c82fb4f301 | ||
|
|
736270b08c | ||
|
|
855c9a1ffc | ||
|
|
b845eda58a | ||
|
|
6571966228 | ||
|
|
c727460251 | ||
|
|
7c7e353114 | ||
|
|
78b33f8994 | ||
|
|
324051bdf0 | ||
|
|
33a16c1af9 | ||
|
|
1bfecc2ff1 | ||
|
|
a8282a95f3 | ||
|
|
9fe617aed7 | ||
|
|
22e12b68a8 | ||
|
|
c6ee92aa3d | ||
|
|
089b6885a7 | ||
|
|
e41b26ba0b | ||
|
|
ea81d0298b | ||
|
|
5ebc7394d0 | ||
|
|
b97f2666a0 | ||
|
|
b36e63f530 | ||
|
|
d4dfee8649 | ||
|
|
1042fad6d0 | ||
|
|
1e9a5fde51 | ||
|
|
c586bab84e | ||
|
|
6a5706e846 | ||
|
|
901615e031 | ||
|
|
f988c0df54 | ||
|
|
7db800005c | ||
|
|
a62130fb5c | ||
|
|
80a2667ae1 | ||
|
|
b6847be9f4 | ||
|
|
e8368a5776 | ||
|
|
7b2280f460 | ||
|
|
ef06cd2310 | ||
|
|
ede685ada8 | ||
|
|
d1be6aa6f2 | ||
|
|
f97e6958d5 | ||
|
|
227da127ae | ||
|
|
11619f8918 | ||
|
|
0bf308438f | ||
|
|
d62d4d3b47 | ||
|
|
b05bb4f48e |
21
.github/ISSUE_TEMPLATE/bug.md.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/bug.md.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a problem you encountered
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> **Please write and communicate in English.**
|
||||
|
||||
### Version
|
||||
Which version are you using?
|
||||
|
||||
### Steps to Reproduce
|
||||
Describe the steps to reproduce the issue clearly.
|
||||
|
||||
### Issue Description
|
||||
What is the problem you encountered?
|
||||
|
||||
### Screenshots or Logs (optional)
|
||||
Attach any screenshots or logs if available.
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
18
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
title: "[FEATURE] "
|
||||
labels: ["enhancement"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> **Please write and communicate in English.**
|
||||
|
||||
### Description
|
||||
What feature would you like to see?
|
||||
|
||||
### Purpose / Use Case
|
||||
Why is this feature needed? What problem does it solve?
|
||||
|
||||
### Additional Information (optional)
|
||||
Any extra ideas or context.
|
||||
15
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
15
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Other Issue
|
||||
about: Any other question or topic
|
||||
title: ""
|
||||
labels: ["question"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> **Please write and communicate in English.**
|
||||
|
||||
### Description
|
||||
Describe your question or topic.
|
||||
|
||||
### Additional Information (optional)
|
||||
Provide any additional context if needed.
|
||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -25,11 +25,31 @@ jobs:
|
||||
os: macos-latest
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
- 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:
|
||||
@@ -56,11 +76,19 @@ jobs:
|
||||
if: matrix.target == 'x86_64-unknown-linux-musl'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
- name: Build aarch64 Rust backend
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
cargo install cross --force
|
||||
cross build --release --features vendored-openssl --target=${{ matrix.target }}
|
||||
|
||||
- name: Build Rust backend
|
||||
run: cargo build --release --features vendored-openssl --target=${{ matrix.target }}
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
cargo build --release --features vendored-openssl --target=${{ matrix.target }}
|
||||
|
||||
- name: Strip binary (Linux and macOS)
|
||||
if: matrix.os != 'windows-latest'
|
||||
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
|
||||
|
||||
@@ -144,15 +172,31 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Linux musl artifact
|
||||
- name: Download Linux x86_64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl
|
||||
name: x86_64-unknown-linux-gnu
|
||||
path: artifacts
|
||||
|
||||
- name: Download Linux aarch64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: aarch64-unknown-linux-gnu
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare Docker context
|
||||
- name: Extract amd64 binary
|
||||
run: |
|
||||
tar -xzf artifacts/${{ env.BINARY_NAME }}-*.tar.gz -C docker
|
||||
mkdir -p docker/amd64
|
||||
AMD64_FILE=$(ls artifacts | grep x86_64-unknown-linux-gnu.tar.gz)
|
||||
echo "Extracting $AMD64_FILE"
|
||||
tar -xzf artifacts/$AMD64_FILE -C docker/amd64
|
||||
|
||||
- name: Extract arm64 binary
|
||||
run: |
|
||||
mkdir -p docker/arm64
|
||||
ARM64_FILE=$(ls artifacts | grep aarch64-unknown-linux-gnu.tar.gz)
|
||||
echo "Extracting $ARM64_FILE"
|
||||
tar -xzf artifacts/$ARM64_FILE -C docker/arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -168,6 +212,7 @@ jobs:
|
||||
with:
|
||||
context: ./docker
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
rustmailer/bichon:${{ github.ref_name }}
|
||||
rustmailer/bichon:latest
|
||||
|
||||
2409
Cargo.lock
generated
2409
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
30
Cargo.toml
30
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.0.1"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
@@ -20,7 +20,7 @@ codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.42"
|
||||
clap = { version = "4.5.51", features = ["derive", "env"] }
|
||||
clap = { version = "4.5.53", features = ["derive", "env"] }
|
||||
mimalloc = "0.1.48"
|
||||
native_db = "0.8.2"
|
||||
itertools = "0.14.0"
|
||||
@@ -37,30 +37,30 @@ 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.41"
|
||||
tracing = "0.1.44"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
||||
base64 = "0.22.1"
|
||||
snafu = "0.8.9"
|
||||
reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
"native-tls",
|
||||
# "rustls-tls",
|
||||
#"native-tls",
|
||||
"rustls-tls",
|
||||
"blocking",
|
||||
"socks",
|
||||
] }
|
||||
tokio-socks = "0.5.2"
|
||||
http = "1.3.1"
|
||||
http = "1.4.0"
|
||||
regex = "1.12.2"
|
||||
email_address = "0.2.9"
|
||||
futures = "0.3.31"
|
||||
utf7-imap = "0.3.2"
|
||||
imap-proto = "0.16.6"
|
||||
mail-parser = { version = '0.11.1', features = ["serde"] }
|
||||
mail-send = "0.5.2"
|
||||
# mail-send = "0.5.2"
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
"ring",
|
||||
"tls12",
|
||||
@@ -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.0"
|
||||
rustls-pki-types = "1.13.2"
|
||||
tokio-io-timeout = "1.2.1"
|
||||
bb8 = "0.9.0"
|
||||
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.2"
|
||||
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"
|
||||
|
||||
351
README.md
351
README.md
@@ -22,56 +22,50 @@
|
||||
<img src="https://img.shields.io/badge/license-AGPLv3-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://deepwiki.com/rustmailer/bichon"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
<a href="https://discord.gg/evFnSpdpaE">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
|
||||
<a href="https://discord.gg/Bq4M2cDmF4">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
|
||||
</a>
|
||||
<a href="https://x.com/rustmailer">
|
||||
<img src="https://img.shields.io/twitter/follow/rustmailer?style=social" alt="Follow on X">
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Bichon is a minimal, high-performance, standalone Rust email archiver with a built-in WebUI.
|
||||
Its name is inspired by the puppy my daughter adopted last month.
|
||||
It runs as a single binary, requires no external dependencies, and provides fast, efficient email archiving, management, and search.
|
||||
Bichon is an open-source email archiving system that **synchronizes emails from IMAP servers**, **indexes them for full-text search**, and provides a **REST API** for programmatic access.
|
||||
**Unlike email clients**, Bichon is designed for **archiving and searching** rather than sending/receiving emails. It runs as a **standalone server application** that continuously synchronizes configured email accounts and maintains a **searchable local archive**.
|
||||
Built in Rust, it requires no external dependencies and provides fast, efficient email archiving, management, and search through a built-in WebUI. Its name is inspired by the puppy my daughter adopted last month.
|
||||
|
||||
## Key Differences from Email Clients
|
||||
|
||||
### Core Comparison
|
||||
|
||||
| Feature | Email Clients | Bichon |
|
||||
|---------|---------------|--------|
|
||||
| **Primary Purpose** | Send/receive emails, real-time communication | Archive, search, manage historical emails |
|
||||
| **Sending Capability** | ✅ Supports sending emails | ❌ No email sending support |
|
||||
| **Runtime Mode** | Desktop/mobile applications | Server-side application |
|
||||
| **Data Storage** | Local cache + server | Local archive store |
|
||||
| **Search Capability** | Basic search | Full-text indexing, advanced search |
|
||||
| **API Interface** | Typically not provided | Complete REST API |
|
||||
| **Multi-account Management** | Limited | Supports unified search across accounts |
|
||||
|
||||
|
||||
## 🚀 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**.
|
||||
|
||||
### 🛠️ OpenAPI Support
|
||||
- Provides **OpenAPI documentation**.
|
||||
- **Access token authentication** for programmatic access.
|
||||
|
||||
## 🐾 Why Create Bichon?
|
||||
|
||||
@@ -100,7 +94,11 @@ It’s not perfect, but I hope it brings you value.
|
||||
<img width="1915" height="903" alt="image" src="https://github.com/user-attachments/assets/242817d7-3e12-4cbb-afb0-c5ef7366178d" />
|
||||
<img width="1920" height="910" alt="image" src="https://github.com/user-attachments/assets/14561b74-ed53-4017-9c5b-a64920ec3526" />
|
||||
<img width="1913" height="909" alt="image" src="https://github.com/user-attachments/assets/6fd54cb0-c86f-4ceb-a955-c81107614fc4" />
|
||||
<img width="1916" height="814" alt="image" src="https://github.com/user-attachments/assets/6a079d98-ff6c-46f4-9ec6-e76d320bff5d" />
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#rustmailer/bichon&type=date&legend=top-left)
|
||||
|
||||
|
||||
## 🚀 Quick Start
|
||||
@@ -124,26 +122,116 @@ docker run -d \
|
||||
rustmailer/bichon:latest
|
||||
```
|
||||
|
||||
* If you are accessing Bichon on the same machine where it is installed (Machine A), open:
|
||||
```
|
||||
http://localhost:15630
|
||||
```
|
||||
## CORS Configuration (Important for Browser Access)
|
||||
|
||||
* If you are accessing Bichon from another machine (Machine B), make sure to set CORS with the IP of Machine B, for example:
|
||||
Starting from **v0.1.4**, Bichon changes how `BICHON_CORS_ORIGINS` works:
|
||||
|
||||
### **🔄 New Behavior in v0.1.4**
|
||||
|
||||
* If **`BICHON_CORS_ORIGINS` is not set**, Bichon now **allows all origins**.
|
||||
This makes local testing and simple deployments much easier.
|
||||
* If you **do set** `BICHON_CORS_ORIGINS`, then **you must explicitly list each allowed origin**.
|
||||
* `*` is **not supported** and will **not work** — you must provide exact URLs.
|
||||
|
||||
#### How CORS Matching Works
|
||||
|
||||
When a browser accesses Bichon, it will send an `Origin` header.
|
||||
|
||||
* **Incoming Origin** = the exact address the browser is using
|
||||
* **Configured origins** = the list you passed to `BICHON_CORS_ORIGINS`
|
||||
|
||||
If Configured origins does not contain the Incoming Origin exactly as a full string match, the browser request will be rejected.
|
||||
|
||||
Example debug log:
|
||||
|
||||
```
|
||||
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Incoming Origin = "http://localhost:15630"
|
||||
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Configured origins = ["http://192.168.3.2:15630"]
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
* Browser is using `http://localhost:15630`
|
||||
* But the configured origin is `http://192.168.3.2:15630`
|
||||
|
||||
→ **CORS will fail**, and you can immediately see why.
|
||||
|
||||
#### When Should You Configure CORS?
|
||||
|
||||
It is strongly recommended to configure CORS in production environments to ensure that only trusted browser origins can access Bichon.
|
||||
If you want to access Bichon from a browser:
|
||||
|
||||
* Add the exact **IP** with port
|
||||
* Or the exact **hostname** with port
|
||||
* Or the **domain** (port optional if it's 80)
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
http://192.168.1.16:15630
|
||||
http://myserver.local:15630
|
||||
http://mydomain.com
|
||||
```
|
||||
|
||||
If you access Bichon in **multiple different ways**, list all of them:
|
||||
|
||||
```
|
||||
-e BICHON_CORS_ORIGINS="http://192.168.1.16:15630,http://myserver.local:15630,http://mydomain.com"
|
||||
```
|
||||
|
||||
> **Do not add a trailing slash**
|
||||
> (`http://192.168.1.16:15630/` will not match)
|
||||
>
|
||||
> **Do not use `*`**, it is not supported.
|
||||
|
||||
#### How to Enable Debug Logs (Highly Recommended for CORS Issues)
|
||||
|
||||
Set environment variable:
|
||||
|
||||
```
|
||||
BICHON_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Or via command-line:
|
||||
|
||||
```
|
||||
--bichon-log-level debug
|
||||
```
|
||||
|
||||
Default is `info`, so CORS logs will not appear unless debug logging is enabled.
|
||||
|
||||
---
|
||||
|
||||
#### ⚠️ Note on Running Bichon in a Container
|
||||
|
||||
> ⚠️ **Note:** If you are running Bichon in a container (via **Docker Compose** or **docker run**), be careful with **quotes in environment variable values**.
|
||||
|
||||
For example, **do not** write:
|
||||
|
||||
```bash
|
||||
# Run container
|
||||
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_CORS_ORIGINS="http://localhost:15630,http://B_MACHINE_IP:15630,*" \
|
||||
rustmailer/bichon:latest
|
||||
-e BICHON_CORS_ORIGINS="http://localhost:15630,http://myserver.local:15630"
|
||||
```
|
||||
Access instructions:
|
||||
This allows Machine B to access the Bichon interface on Machine A via a browser.
|
||||
|
||||
* The outer quotes (`"`) will be passed literally into the container and may cause CORS misconfiguration.
|
||||
|
||||
**Correct way:**
|
||||
|
||||
```bash
|
||||
-e BICHON_CORS_ORIGINS=http://localhost:15630,http://myserver.local:15630
|
||||
```
|
||||
|
||||
Or using YAML literal style for Docker Compose:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
BICHON_CORS_ORIGINS: |
|
||||
http://localhost:15630,http://myserver.local:15630
|
||||
```
|
||||
|
||||
This ensures that the configured origins are interpreted correctly inside the container.
|
||||
|
||||
> ⚠️ **Note:** This fucking problem I actually didn’t know about myself; thanks to [gall-1](https://github.com/gall-1) for pointing it out.
|
||||
|
||||
|
||||
### Binary Deployment
|
||||
|
||||
@@ -164,24 +252,138 @@ Extract and run:
|
||||
.\bichon.exe --bichon-root-dir e:\bichon-data
|
||||
```
|
||||
|
||||
* --bichon-root-dir argument is required and must be an absolute path.
|
||||
|
||||
* If you are accessing Bichon from a proxy domain **mydomain** argument --bichon-cors-origins="https://mydomain" is required.
|
||||
|
||||
## 🔐 Setting the Bichon Encryption Password
|
||||
|
||||
Please refer to the following documentation for detailed instructions on how to set the Bichon encryption password:
|
||||
|
||||
👉 [https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password](https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password)
|
||||
|
||||
All configuration methods, including command-line options, environment variables, and password file support (v0.2.0+), are documented there.
|
||||
|
||||
## 🔑 User Authentication & Admin Account
|
||||
|
||||
Starting from **Bichon v0.2.0**, the authentication model has been updated.
|
||||
|
||||
### Built-in Admin User (v0.2.0+)
|
||||
|
||||
* 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:**
|
||||
|
||||
* **Username:** `admin`
|
||||
* **Password:** `admin@bichon`
|
||||
|
||||
> The legacy `root` account and the `root / root` default credentials **no longer exist**.
|
||||
|
||||
|
||||
### Mandatory Access Token Authentication
|
||||
|
||||
* From **v0.2.0 onward**, **access-token–based authentication is always enabled**.
|
||||
* The startup flag and environment variable
|
||||
`--bichon-enable-access-token` / `BICHON_ENABLE_ACCESS_TOKEN`
|
||||
are **deprecated and no longer used**.
|
||||
* No additional configuration is required to enable authentication.
|
||||
|
||||
|
||||
### Managing Account Information
|
||||
|
||||
After logging in, the admin user can manage their profile directly in the WebUI:
|
||||
|
||||
1. Log in to the WebUI using the default admin credentials.
|
||||
2. Navigate to **Settings → Profile**.
|
||||
3. Update:
|
||||
|
||||
* 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
|
||||
|
||||
> Under construction. Documentation will be available soon.
|
||||
[Bichon Wiki](https://github.com/rustmailer/bichon/wiki).
|
||||
|
||||
## FAQ
|
||||
|
||||
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
|
||||
- **Frontend**: React + TypeScript + Vite + ShadCN
|
||||
- **Storage**: Native_DB
|
||||
- **Search Engine**: Tantivy
|
||||
- **Email Protocols**: IMAP (Password & OAuth2)
|
||||
|
||||
- **Frontend**: React + TypeScript + Vite + ShadCN UI
|
||||
- **Core Engine (Storage & Search)**: Tantivy
|
||||
- Acts as both the primary storage for email content and the full-text search index. This unified approach ensures high performance and eliminates data redundancy.
|
||||
- **Metadata Storage**: Native_DB
|
||||
- Used exclusively for lightweight configuration and account metadata.
|
||||
- **Email Protocols**: IMAP (Supports standard Password & OAuth2)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
Contributions of all kinds are welcome!
|
||||
Whether you’d like to submit code, report a bug, or share practical suggestions that can help improve the project, your input is highly appreciated.
|
||||
Feel free to open an Issue or a Pull Request anytime. You can also reach out on Discord if you’d like to discuss ideas or improvements.
|
||||
<a href="https://discord.gg/Bq4M2cDmF4">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
|
||||
</a>
|
||||
|
||||
## 🧑💻 Developer Guide
|
||||
|
||||
@@ -198,7 +400,7 @@ To build or contribute to Bichon, the following environment is recommended:
|
||||
```bash
|
||||
git clone https://github.com/rustmailer/bichon.git
|
||||
cd bichon
|
||||
````
|
||||
```
|
||||
|
||||
#### 2. Build the WebUI
|
||||
|
||||
@@ -226,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
|
||||
|
||||
@@ -243,12 +448,16 @@ This project is licensed under [AGPLv3](LICENSE).
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [Official Website](https://rustmailer.com)
|
||||
- [Docker Hub](https://hub.docker.com/r/rustmailer/bichon)
|
||||
- [Issue Tracker](https://github.com/rustmailer/bichon/issues)
|
||||
- [Discord](https://discord.gg/evFnSpdpaE)
|
||||
---
|
||||
- [Discord](https://discord.gg/Bq4M2cDmF4)
|
||||
|
||||
<div align="center">
|
||||
Made with ❤️ by rustmailer.com
|
||||
</div>
|
||||
|
||||
## 💖 Support & Promotion
|
||||
|
||||
If this project has been helpful to you and you’d like to support its development, you can consider making a small donation or helping spread the word.
|
||||
Financial support is optional but deeply appreciated — it helps me dedicate more time and resources to building new features and improving the overall experience.
|
||||
|
||||
You can also support the project by sharing it with others, writing about your experience, or recommending it within relevant communities. Every bit of visibility helps more people benefit from the tool!
|
||||
|
||||
[](https://buymeacoffee.com/rustmailer)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Use Debian bullseye-slim as the base image for newer OpenSSL
|
||||
FROM ubuntu:24.04
|
||||
ARG CRATE_VERSION
|
||||
ARG TARGETARCH
|
||||
|
||||
# Set maintainer metadata
|
||||
LABEL maintainer="rustmailer <rustmailer.git@gmail.com>"
|
||||
@@ -11,15 +12,13 @@ LABEL description="Dockerized Bichon service"
|
||||
WORKDIR /opt/bichon
|
||||
|
||||
# Copy compiled binary (ensure it's statically linked or compatible with bullseye)
|
||||
COPY bichon /opt/bichon/bichon
|
||||
COPY LICENSE /opt/bichon/
|
||||
COPY ${TARGETARCH}/bichon /opt/bichon/bichon
|
||||
COPY ${TARGETARCH}/LICENSE /opt/bichon/
|
||||
# Set proper permissions
|
||||
RUN chmod +x /opt/bichon/bichon
|
||||
|
||||
# Install ca-certificates to ensure HTTPS certificate verification works correctly
|
||||
RUN apt update && apt install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt update && apt install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /data
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -70,7 +70,6 @@ pub struct AuthConfig {
|
||||
///
|
||||
/// Users should provide a plaintext password (1 to 256 characters).
|
||||
/// The server will encrypt the password using AES-256-GCM and securely store it.
|
||||
/// The plaintext password is never stored, so users must remember it for authentication.
|
||||
#[oai(validator(max_length = 256, min_length = 1))]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
160
src/modules/account/grant.rs
Normal file
160
src/modules/account/grant.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -28,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,
|
||||
};
|
||||
@@ -53,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 = AccountV1;
|
||||
pub type AccountModel = AccountV3;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
|
||||
pub enum AccountType {
|
||||
@@ -87,13 +90,77 @@ pub struct AccountV1 {
|
||||
pub updated_at: i64,
|
||||
pub use_proxy: Option<u64>,
|
||||
}
|
||||
|
||||
impl AccountV1 {
|
||||
fn pk(&self) -> String {
|
||||
format!("{}_{}", self.created_at, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
#[native_model(id = 4, version = 2, from = AccountV1)]
|
||||
#[native_db(primary_key(pk -> String))]
|
||||
pub struct AccountV2 {
|
||||
#[secondary_key(unique)]
|
||||
pub id: u64,
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub folder_limit: Option<u32>,
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub sync_interval_min: Option<i64>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub use_proxy: Option<u64>,
|
||||
pub use_dangerous: bool,
|
||||
pub pgp_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountV2 {
|
||||
fn pk(&self) -> String {
|
||||
format!("{}_{}", self.created_at, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -110,12 +177,17 @@ impl AccountV1 {
|
||||
updated_at: utc_now!(),
|
||||
use_proxy: request.use_proxy,
|
||||
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_active(account_id: u64) -> BichonResult<AccountModel> {
|
||||
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
|
||||
let account =
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -123,13 +195,6 @@ impl AccountV1 {
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
if !account.enabled {
|
||||
return Err(raise_error!(
|
||||
format!("Account id='{account_id}' is disabled"),
|
||||
ErrorCode::AccountDisabled
|
||||
));
|
||||
}
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
@@ -145,24 +210,48 @@ impl AccountV1 {
|
||||
}
|
||||
|
||||
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::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(
|
||||
@@ -199,7 +288,7 @@ impl AccountV1 {
|
||||
|
||||
async fn delete_account(account_id: u64) -> BichonResult<()> {
|
||||
delete_impl(DB_MANAGER.meta_db(), move|rw|{
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
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
|
||||
}
|
||||
@@ -211,7 +300,7 @@ impl AccountV1 {
|
||||
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)
|
||||
@@ -229,7 +318,7 @@ impl AccountV1 {
|
||||
sync_folders: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
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();
|
||||
@@ -244,7 +333,7 @@ impl AccountV1 {
|
||||
known_folders: BTreeSet<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
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();
|
||||
@@ -259,7 +348,7 @@ impl AccountV1 {
|
||||
capabilities: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
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();
|
||||
@@ -278,7 +367,7 @@ impl AccountV1 {
|
||||
let result = list_all_impl(DB_MANAGER.meta_db())
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|a: &AccountModel| a.enabled)
|
||||
//.filter(|a: &AccountModel| a.enabled)
|
||||
.map(|account: AccountModel| MinimalAccount {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
@@ -288,7 +377,7 @@ impl AccountV1 {
|
||||
}
|
||||
|
||||
pub async fn count() -> BichonResult<usize> {
|
||||
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id)
|
||||
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -311,6 +400,19 @@ impl AccountV1 {
|
||||
|
||||
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 {
|
||||
@@ -318,17 +420,26 @@ impl AccountV1 {
|
||||
}
|
||||
|
||||
if let Some(name) = &request.name {
|
||||
new.name = Some(name.clone());
|
||||
if name.trim().is_empty() {
|
||||
new.name = None;
|
||||
} else {
|
||||
new.name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(old.account_type, AccountType::IMAP) {
|
||||
if let Some(imap) = &request.imap {
|
||||
let mut new_imap = imap.clone();
|
||||
if let Some(password) = &new_imap.auth.password {
|
||||
let encrypted_password = encrypt!(password)?;
|
||||
new_imap.auth.password = Some(encrypted_password);
|
||||
if let Some(current_imap) = &mut new.imap {
|
||||
current_imap.host = imap.host.clone();
|
||||
current_imap.port = imap.port.clone();
|
||||
current_imap.encryption = imap.encryption.clone();
|
||||
current_imap.auth.auth_type = imap.auth.auth_type.clone();
|
||||
if let Some(password) = &imap.auth.password {
|
||||
let encrypted_password = encrypt!(password)?;
|
||||
current_imap.auth.password = Some(encrypted_password);
|
||||
}
|
||||
current_imap.use_proxy = imap.use_proxy;
|
||||
}
|
||||
new.imap = Some(new_imap);
|
||||
}
|
||||
|
||||
if let Some(folder_names) = request.sync_folders {
|
||||
@@ -337,6 +448,11 @@ impl AccountV1 {
|
||||
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);
|
||||
}
|
||||
@@ -351,7 +467,113 @@ impl AccountV1 {
|
||||
if let Some(enabled) = request.enabled {
|
||||
new.enabled = enabled;
|
||||
}
|
||||
|
||||
if let Some(use_dangerous) = request.use_dangerous {
|
||||
new.use_dangerous = use_dangerous;
|
||||
}
|
||||
|
||||
if let Some(pgp_key) = request.pgp_key {
|
||||
new.pgp_key = Some(pgp_key);
|
||||
}
|
||||
|
||||
new.updated_at = utc_now!();
|
||||
Ok(new)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountV1> for AccountV2 {
|
||||
fn from(value: AccountV1) -> 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: false,
|
||||
pgp_key: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountV2> for AccountV1 {
|
||||
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,
|
||||
use_proxy: value.use_proxy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::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};
|
||||
@@ -37,19 +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 {
|
||||
@@ -70,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<()> {
|
||||
@@ -106,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).
|
||||
@@ -128,17 +144,45 @@ 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.
|
||||
pub use_proxy: Option<u64>,
|
||||
|
||||
pub use_dangerous: Option<bool>,
|
||||
|
||||
pub pgp_key: Option<String>,
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -162,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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -69,7 +69,7 @@ impl AccountRunningState {
|
||||
errors: vec![],
|
||||
is_initial_sync_completed: false,
|
||||
progress: None,
|
||||
initial_sync_start_time: None,
|
||||
initial_sync_start_time: Some(utc_now!()),
|
||||
initial_sync_end_time: None,
|
||||
initial_sync_failed_time: None,
|
||||
};
|
||||
@@ -125,14 +125,14 @@ impl AccountRunningState {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.initial_sync_start_time = Some(utc_now!());
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
// pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
|
||||
// Self::update_account_running_state(account_id, move |current| {
|
||||
// let mut updated = current.clone();
|
||||
// updated.initial_sync_start_time = Some(utc_now!());
|
||||
// Ok(updated)
|
||||
// })
|
||||
// .await
|
||||
// }
|
||||
|
||||
pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
|
||||
91
src/modules/account/view.rs
Normal file
91
src/modules/account/view.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/modules/cache/imap/sync/flow.rs
vendored
103
src/modules/cache/imap/sync/flow.rs
vendored
@@ -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?;
|
||||
}
|
||||
|
||||
30
src/modules/cache/imap/sync/mod.rs
vendored
30
src/modules/cache/imap/sync/mod.rs
vendored
@@ -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::{
|
||||
@@ -24,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};
|
||||
@@ -46,18 +45,35 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
|
||||
let start_time = Instant::now();
|
||||
let account_id = account.id;
|
||||
let sync_type = determine_sync_type(account).await?;
|
||||
|
||||
if matches!(sync_type, SyncType::SkipSync) {
|
||||
return Ok(());
|
||||
}
|
||||
let remote_mailboxes = get_sync_folders(account).await?;
|
||||
if matches!(sync_type, SyncType::InitialSync) {
|
||||
AccountRunningState::set_initial_sync_start(account_id).await?;
|
||||
AccountRunningState::add(account.id).await?;
|
||||
// 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(_) => {
|
||||
|
||||
32
src/modules/cache/imap/sync/rebuild.rs
vendored
32
src/modules/cache/imap/sync/rebuild.rs
vendored
@@ -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
|
||||
|
||||
6
src/modules/cache/imap/sync/sync_type.rs
vendored
6
src/modules/cache/imap/sync/sync_type.rs
vendored
@@ -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},
|
||||
@@ -51,10 +50,7 @@ pub async fn determine_sync_type(account: &AccountModel) -> BichonResult<SyncTyp
|
||||
SyncType::SkipSync
|
||||
}
|
||||
}
|
||||
None => {
|
||||
AccountRunningState::add(account.id).await?;
|
||||
SyncType::InitialSync
|
||||
}
|
||||
None => SyncType::InitialSync,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,19 +16,14 @@
|
||||
// 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 super::error::code::ErrorCode;
|
||||
use super::error::BichonError;
|
||||
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
|
||||
use mail_send::mail_builder::headers::address::Address as SmtpAddress;
|
||||
use mail_send::mail_builder::headers::address::EmailAddress as SmtpEmailAddress;
|
||||
use poem::error::ResponseError;
|
||||
use poem::Body;
|
||||
use poem::{http::StatusCode, Error, Response};
|
||||
use poem_openapi::Object;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Deref;
|
||||
use tracing::error;
|
||||
|
||||
@@ -53,38 +48,6 @@ pub struct Addr {
|
||||
pub address: Option<String>,
|
||||
}
|
||||
|
||||
impl Addr {
|
||||
pub fn parse(s: &str) -> Self {
|
||||
let re = Regex::new(r#"(?:(?P<name>.*)\s*)?<(?P<email>[^<>]+)>"#).unwrap();
|
||||
if let Some(caps) = re.captures(s) {
|
||||
let name: Option<String> = caps.name("name").map(|m| m.as_str().trim().into());
|
||||
let email: Option<String> = caps.name("email").map(|m| m.as_str().trim().into());
|
||||
Addr {
|
||||
name: if let Some(n) = name {
|
||||
if n.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(n)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
},
|
||||
address: email,
|
||||
}
|
||||
} else {
|
||||
let s_trimmed = s.trim();
|
||||
Addr {
|
||||
name: None,
|
||||
address: if s_trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s_trimmed.into())
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Addr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match (&self.name, &self.address) {
|
||||
@@ -129,50 +92,6 @@ impl<'x> From<&ImapAddress<'x>> for AddrVec {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SmtpEmailAddress<'x>> for Addr {
|
||||
fn from(email: SmtpEmailAddress<'x>) -> Self {
|
||||
Addr {
|
||||
name: email.name.map(|n| n.into_owned()),
|
||||
address: Some(email.email.into_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<&SmtpAddress<'x>> for AddrVec {
|
||||
fn from(address: &SmtpAddress<'x>) -> Self {
|
||||
fn collect_addresses<'x>(address: &SmtpAddress<'x>, result: &mut Vec<Addr>) {
|
||||
match address {
|
||||
SmtpAddress::Address(email) => {
|
||||
let addr = Addr::from(email.clone());
|
||||
result.push(addr);
|
||||
}
|
||||
SmtpAddress::Group(group) => {
|
||||
for addr in &group.addresses {
|
||||
collect_addresses(addr, result);
|
||||
}
|
||||
}
|
||||
SmtpAddress::List(list) => {
|
||||
for addr in list {
|
||||
collect_addresses(addr, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut addresses = Vec::new();
|
||||
collect_addresses(address, &mut addresses);
|
||||
AddrVec(addresses)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<Addr> for SmtpAddress<'x> {
|
||||
fn from(addr: Addr) -> Self {
|
||||
SmtpAddress::Address(SmtpEmailAddress {
|
||||
name: addr.name.map(Cow::Owned),
|
||||
email: Cow::Owned(addr.address.unwrap_or_default()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// #[derive(Serialize)]
|
||||
// pub struct ErrorResponse {
|
||||
// pub message: String,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::AccountType;
|
||||
use crate::modules::context::Initialize;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::raise_error;
|
||||
@@ -33,8 +33,7 @@ use dashmap::DashMap;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tracing::info;
|
||||
|
||||
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> =
|
||||
LazyLock::new(EmailClientExecutors::new);
|
||||
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> = LazyLock::new(EmailClientExecutors::new);
|
||||
|
||||
pub struct EmailClientExecutors {
|
||||
start_at: i64,
|
||||
@@ -64,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()),
|
||||
@@ -88,15 +87,17 @@ impl EmailClientExecutors {
|
||||
|
||||
pub async fn start_account_syncers(&self) -> BichonResult<()> {
|
||||
let accounts = AccountModel::list_all().await?;
|
||||
let active_accounts: Vec<AccountModel> =
|
||||
accounts.into_iter().filter(|a| a.enabled).collect();
|
||||
let active_accounts: Vec<AccountModel> = accounts
|
||||
.into_iter()
|
||||
.filter(|a| a.enabled && matches!(a.account_type, AccountType::IMAP))
|
||||
.collect();
|
||||
|
||||
if active_accounts.is_empty() {
|
||||
info!("No active accounts found for account initialization.");
|
||||
return Ok(());
|
||||
}
|
||||
info!(
|
||||
"System has {} active accounts to initialize.",
|
||||
"System has {} active IMAP accounts to initialize.",
|
||||
active_accounts.len()
|
||||
);
|
||||
for account in active_accounts {
|
||||
|
||||
@@ -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,
|
||||
@@ -45,18 +48,52 @@ pub struct DashboardStats {
|
||||
pub with_attachment_count: u64, // Emails with attachments
|
||||
pub without_attachment_count: u64, // Emails without attachments
|
||||
pub top_largest_emails: Vec<LargestEmail>, // Top 10 largest emails
|
||||
pub system_version: String, // The semantic version string of the currently running backend service
|
||||
pub commit_hash: String, // Git commit hash used to build this system version
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +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::account::migration::AccountModel;
|
||||
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};
|
||||
@@ -71,6 +72,10 @@ impl DatabaseManager {
|
||||
let rw = database
|
||||
.rw_transaction()
|
||||
.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))?;
|
||||
|
||||
|
||||
@@ -16,8 +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;
|
||||
use crate::modules::account::migration::{AccountV1, AccountV2, AccountV3};
|
||||
use crate::modules::autoconfig::CachedMailSettings;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
@@ -26,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;
|
||||
@@ -59,14 +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(¤t_item)?;
|
||||
rw.update(current_item.clone(), updated_item)
|
||||
rw.update(current_item, updated_item.clone())
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(current_item)
|
||||
Ok(updated_item)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
@@ -223,20 +231,20 @@ pub async fn async_find_impl<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.
|
||||
|
||||
@@ -16,16 +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::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
|
||||
@@ -33,12 +32,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
.map(|d| d.timestamp_millis())
|
||||
.unwrap_or(0);
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let size = fetch.size.unwrap_or(0);
|
||||
|
||||
let body = fetch
|
||||
.body()
|
||||
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
|
||||
|
||||
let size = fetch.size.unwrap_or(body.len() as u32);
|
||||
let message = MessageParser::new().parse(body).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Email header parse result is not available".into(),
|
||||
@@ -49,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()
|
||||
@@ -62,7 +63,13 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
let in_reply_to = message.in_reply_to().as_text().map(String::from);
|
||||
let references = extract_references(&message);
|
||||
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
|
||||
let subject = message.subject().map(String::from).unwrap_or("".into());
|
||||
|
||||
let mut subject = message.subject().map(String::from).unwrap_or_default();
|
||||
|
||||
if subject.contains('\u{FFFD}') {
|
||||
subject = normalize_subject(message.header_raw(HeaderName::Subject));
|
||||
}
|
||||
|
||||
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
|
||||
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
@@ -114,6 +121,103 @@ 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)
|
||||
}
|
||||
|
||||
pub fn extract_envelope_from_eml(
|
||||
body: &[u8],
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
) -> BichonResult<Envelope> {
|
||||
let uid = 0;
|
||||
let size = body.len() as u32;
|
||||
let message = MessageParser::new().parse(body).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Email header parse result is not available".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
|
||||
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()) {
|
||||
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()
|
||||
};
|
||||
|
||||
let message_id = message
|
||||
.message_id()
|
||||
.map(String::from)
|
||||
.unwrap_or(generate_message_id());
|
||||
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 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)
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|a| a.address)
|
||||
.collect()
|
||||
});
|
||||
let cc: Option<Vec<String>> = message.cc().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|a| a.address)
|
||||
.collect()
|
||||
});
|
||||
let to: Option<Vec<String>> = message.to().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|a| a.address)
|
||||
.collect()
|
||||
});
|
||||
let from = message
|
||||
.from()
|
||||
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
|
||||
.and_then(|add| add.address)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let attachments: Vec<String> = message
|
||||
.attachments()
|
||||
.filter_map(|att| att.attachment_name())
|
||||
.map(|name| name.to_string())
|
||||
.collect();
|
||||
let envelope = Envelope {
|
||||
id: create_hash(account_id, &message_id),
|
||||
message_id,
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
subject,
|
||||
text,
|
||||
from,
|
||||
to: to.unwrap_or_default(),
|
||||
cc: cc.unwrap_or_default(),
|
||||
bcc: bcc.unwrap_or_default(),
|
||||
date,
|
||||
internal_date: date,
|
||||
size,
|
||||
thread_id,
|
||||
attachments,
|
||||
tags: None,
|
||||
account_email: None,
|
||||
mailbox_name: None,
|
||||
};
|
||||
Ok(envelope)
|
||||
}
|
||||
@@ -144,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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
src/modules/envelope/utils.rs
Normal file
114
src/modules/envelope/utils.rs
Normal 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?=");
|
||||
}
|
||||
}
|
||||
@@ -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 (20000–20999)
|
||||
PermissionDenied = 20000,
|
||||
AccountDisabled = 20010,
|
||||
Forbidden = 20020,
|
||||
OAuth2ItemDisabled = 20050,
|
||||
MissingRefreshToken = 20060,
|
||||
|
||||
// Resource errors (30000–30999)
|
||||
ResourceNotFound = 30000,
|
||||
TooManyRequest = 30020,
|
||||
AlreadyExists = 30030,
|
||||
|
||||
// Network connection errors (40000–40999)
|
||||
NetworkError = 40000,
|
||||
@@ -64,11 +65,14 @@ impl ErrorCode {
|
||||
| ErrorCode::MissingConfiguration
|
||||
| ErrorCode::Incompatible => StatusCode::BAD_REQUEST,
|
||||
ErrorCode::PermissionDenied => StatusCode::UNAUTHORIZED,
|
||||
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled => StatusCode::FORBIDDEN,
|
||||
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled | ErrorCode::Forbidden => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
ErrorCode::ResourceNotFound => StatusCode::NOT_FOUND,
|
||||
ErrorCode::RequestTimeout => StatusCode::REQUEST_TIMEOUT,
|
||||
ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
ErrorCode::TooManyRequest => StatusCode::TOO_MANY_REQUESTS,
|
||||
ErrorCode::AlreadyExists => StatusCode::CONFLICT,
|
||||
ErrorCode::InternalError
|
||||
| ErrorCode::AutoconfigFetchFailed
|
||||
| ErrorCode::ImapCommandFailed
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::imap::session::SessionStream;
|
||||
use crate::{modules::error::BichonResult, raise_error};
|
||||
|
||||
@@ -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::entity::Encryption;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
@@ -100,15 +99,17 @@ impl Client {
|
||||
encryption: &Encryption,
|
||||
port: u16,
|
||||
use_proxy: Option<u64>,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<Self> {
|
||||
let resolved_addr = Self::resolve_to_socket_addr(domain, port)?;
|
||||
debug!("Attempting IMAP connection to {domain} ({resolved_addr}).");
|
||||
match encryption {
|
||||
Encryption::Ssl => {
|
||||
Self::establish_secure_connection(resolved_addr, domain, use_proxy).await
|
||||
Self::establish_secure_connection(resolved_addr, domain, use_proxy, dangerous).await
|
||||
}
|
||||
Encryption::StartTls => {
|
||||
Self::establish_starttls_connection(resolved_addr, domain, use_proxy).await
|
||||
Self::establish_starttls_connection(resolved_addr, domain, use_proxy, dangerous)
|
||||
.await
|
||||
}
|
||||
Encryption::None => Self::establish_insecure_connection(resolved_addr, use_proxy).await,
|
||||
}
|
||||
@@ -118,11 +119,17 @@ impl Client {
|
||||
address: SocketAddr,
|
||||
server_hostname: &str,
|
||||
use_proxy: Option<u64>,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<Self> {
|
||||
// Establish the TLS connection with the specified parameters
|
||||
let tls_stream =
|
||||
establish_tls_connection(address, server_hostname, alpn(address.port()), use_proxy)
|
||||
.await?;
|
||||
let tls_stream = establish_tls_connection(
|
||||
address,
|
||||
server_hostname,
|
||||
alpn(address.port()),
|
||||
use_proxy,
|
||||
dangerous,
|
||||
)
|
||||
.await?;
|
||||
let stats_stream = StatsWrapper::new(tls_stream);
|
||||
// Wrap the TLS stream in a buffered writer for efficient IO
|
||||
let buffered_stream = BufWriter::new(stats_stream);
|
||||
@@ -137,7 +144,7 @@ impl Client {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"failed to read greeting".into(),
|
||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is SSL.".into(),
|
||||
ErrorCode::ImapCommandFailed
|
||||
)
|
||||
})?;
|
||||
@@ -180,6 +187,7 @@ impl Client {
|
||||
address: SocketAddr,
|
||||
server_hostname: &str,
|
||||
use_proxy: Option<u64>,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<Self> {
|
||||
// Establish the initial TCP connection
|
||||
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
|
||||
@@ -197,7 +205,7 @@ impl Client {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"failed to read greeting".into(),
|
||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is STARTTLS.".into(),
|
||||
ErrorCode::ImapCommandFailed
|
||||
)
|
||||
})?;
|
||||
@@ -217,7 +225,7 @@ impl Client {
|
||||
let buffered_tcp_stream = client.into_inner();
|
||||
let tcp_stream = buffered_tcp_stream.into_inner();
|
||||
// Wrap the TCP stream in TLS encryption
|
||||
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream).await?;
|
||||
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream, dangerous).await?;
|
||||
// Wrap the TLS stream in a buffered writer
|
||||
let buffered_stream = BufWriter::new(tls_stream);
|
||||
// Create a SessionStream trait object for further communication
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::dispatcher::STATUS_DISPATCHER;
|
||||
use crate::modules::account::entity::AuthType;
|
||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
||||
@@ -29,7 +28,7 @@ use crate::modules::imap::client::Client;
|
||||
use crate::modules::imap::oauth2::OAuth2;
|
||||
use crate::modules::imap::session::SessionStream;
|
||||
use crate::modules::oauth2::token::OAuth2AccessToken;
|
||||
use crate::{decrypt, raise_error};
|
||||
use crate::{bichon_version, decrypt, raise_error};
|
||||
use async_imap::Session;
|
||||
use tracing::error;
|
||||
|
||||
@@ -51,7 +50,14 @@ impl ImapConnectionManager {
|
||||
async fn create_client(&self, account: &AccountModel) -> BichonResult<Client> {
|
||||
assert_eq!(account.account_type, AccountType::IMAP);
|
||||
let imap = account.imap.as_ref().unwrap();
|
||||
Client::connection(&imap.host, &imap.encryption, imap.port, imap.use_proxy).await
|
||||
Client::connection(
|
||||
&imap.host,
|
||||
&imap.encryption,
|
||||
imap.port,
|
||||
imap.use_proxy,
|
||||
account.use_dangerous,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
@@ -61,6 +67,7 @@ impl ImapConnectionManager {
|
||||
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
assert_eq!(account.account_type, AccountType::IMAP);
|
||||
let imap = account.imap.as_ref().unwrap();
|
||||
let username = account.name.clone().unwrap_or(account.email.clone());
|
||||
match &imap.auth.auth_type {
|
||||
AuthType::Password => {
|
||||
let password = &imap.auth.password.clone().ok_or_else(|| {
|
||||
@@ -71,7 +78,13 @@ impl ImapConnectionManager {
|
||||
})?;
|
||||
|
||||
let password = decrypt!(&password)?;
|
||||
client.login(&account.email, &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?;
|
||||
@@ -83,8 +96,12 @@ impl ImapConnectionManager {
|
||||
)
|
||||
})?;
|
||||
client
|
||||
.authenticate(OAuth2::new(account.email.clone(), access_token))
|
||||
.authenticate(OAuth2::new(username.clone(), access_token))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("IMAP OAuth2 auth failed for username '{}': {}", username, e);
|
||||
e
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +154,19 @@ impl ImapConnectionManager {
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if capabilities.has_str("ID") || capabilities.has_str("id") {
|
||||
session
|
||||
.id([
|
||||
("name", Some("bichon")),
|
||||
("version", Some(bichon_version!())),
|
||||
("vendor", Some("rustmailer")),
|
||||
])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
error!("Failed to fetch IMAP capabilities: {:#?}", error);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,19 +16,23 @@
|
||||
// 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::{parsers::MessageStream, HeaderName, MessageParser};
|
||||
|
||||
use mail_parser::MessageParser;
|
||||
|
||||
use crate::{base64_encode_url_safe, modules::{account::entity::Encryption, imap::client::Client}};
|
||||
use crate::{
|
||||
base64_encode_url_safe,
|
||||
modules::{
|
||||
account::entity::Encryption, envelope::utils::normalize_subject, imap::client::Client,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn testxx() {
|
||||
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
|
||||
.unwrap();
|
||||
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None)
|
||||
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut session = client.login("pollybase@zohomail.com", "xxx").await.unwrap();
|
||||
let mut session = client.login("xx@zohomail.com", "xxx").await.unwrap();
|
||||
session.select("INBOX").await.unwrap();
|
||||
let result = session.uid_search("LARGER 1024").await.unwrap();
|
||||
println!("{:#?}", result);
|
||||
@@ -46,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);
|
||||
}
|
||||
}
|
||||
|
||||
162
src/modules/import/mod.rs
Normal file
162
src/modules/import/mod.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tantivy::doc;
|
||||
|
||||
use crate::{
|
||||
base64_decode_url_safe,
|
||||
modules::{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
|
||||
envelope::extractor::extract_envelope_from_eml,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
indexer::{
|
||||
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
schema::SchemaTools,
|
||||
},
|
||||
utils::create_hash,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct BatchEmlRequest {
|
||||
pub account_id: u64,
|
||||
pub mail_folder: String,
|
||||
/// A list of emails in base64-encoded format. Each element represents one .eml file.
|
||||
pub emls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct FailedEmlDetail {
|
||||
/// The 0-based index of the failed EML in the request list
|
||||
pub index: usize,
|
||||
/// The error message that caused the import to fail
|
||||
pub error_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct BatchEmlResult {
|
||||
/// Total number of emails processed
|
||||
pub total: usize,
|
||||
/// Number of emails successfully imported
|
||||
pub success: usize,
|
||||
/// Number of emails failed to import
|
||||
pub failed: usize,
|
||||
/// A list of details for failed imports
|
||||
pub failed_details: Vec<FailedEmlDetail>,
|
||||
}
|
||||
|
||||
pub struct ImportEmls;
|
||||
|
||||
impl ImportEmls {
|
||||
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
|
||||
let account = AccountModel::check_account_exists(request.account_id).await?;
|
||||
|
||||
if !account.enabled {
|
||||
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
|
||||
}
|
||||
|
||||
let mailbox_id = match account.account_type {
|
||||
AccountType::IMAP => {
|
||||
let all_mailboxes = MailBox::list_all(account.id).await?;
|
||||
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
|
||||
|
||||
match mailbox {
|
||||
Some(mailbox) => mailbox.id,
|
||||
None => return Err(raise_error!(
|
||||
format!("Mail folder '{}' not found for account ID {}. The target folder must exist before importing.",
|
||||
request.mail_folder,
|
||||
request.account_id).into(),
|
||||
ErrorCode::ResourceNotFound
|
||||
)),
|
||||
}
|
||||
},
|
||||
AccountType::NoSync => {
|
||||
let mailbox = MailBox {
|
||||
id: create_hash(request.account_id, &request.mail_folder),
|
||||
account_id: request.account_id,
|
||||
name: request.mail_folder.clone(),
|
||||
delimiter: Some("/".to_string()),
|
||||
attributes: vec![Attribute {
|
||||
attr: AttributeEnum::Extension,
|
||||
extension: Some("CreatedByBichon".into()),
|
||||
}],
|
||||
exists: 0,
|
||||
unseen: None,
|
||||
uid_next: None,
|
||||
uid_validity: None,
|
||||
};
|
||||
let mailbox_id = mailbox.id;
|
||||
// Upsert the mailbox, creating it if it doesn't exist
|
||||
MailBox::batch_upsert(&[mailbox]).await?;
|
||||
mailbox_id
|
||||
},
|
||||
};
|
||||
|
||||
let fields = SchemaTools::eml_fields();
|
||||
let account_id = account.id;
|
||||
let mut success_count = 0;
|
||||
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
|
||||
|
||||
let total = request.emls.len();
|
||||
for (index, eml_base64) in request.emls.into_iter().enumerate() {
|
||||
// 1. Decode Base64
|
||||
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
let error_msg =
|
||||
format!("Failed to decode base64 EML at index {}: {:?}", index, e);
|
||||
tracing::error!("{}", error_msg);
|
||||
failed_details.push(FailedEmlDetail {
|
||||
index,
|
||||
error_message: error_msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
|
||||
Ok(env) => env,
|
||||
Err(e) => {
|
||||
let error_msg = format!(
|
||||
"Failed to extract envelope from EML at index {}: {:?}",
|
||||
index, e
|
||||
);
|
||||
tracing::error!("{}", error_msg);
|
||||
failed_details.push(FailedEmlDetail {
|
||||
index,
|
||||
error_message: error_msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.add_document(envelope.id, envelope.to_document(mailbox_id).unwrap())
|
||||
.await;
|
||||
|
||||
EML_INDEX_MANAGER
|
||||
.add_document(
|
||||
envelope.id,
|
||||
doc!(
|
||||
fields.f_id => envelope.id,
|
||||
fields.f_account_id => account_id,
|
||||
fields.f_mailbox_id => mailbox_id,
|
||||
fields.f_eml => decoded
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
success_count += 1;
|
||||
}
|
||||
|
||||
let failed_count = failed_details.len();
|
||||
|
||||
Ok(BatchEmlResult {
|
||||
total,
|
||||
success: success_count,
|
||||
failed: failed_count,
|
||||
failed_details, // Return the list of failure details
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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::{path::PathBuf, time::Duration};
|
||||
|
||||
use chrono::Utc;
|
||||
@@ -145,20 +144,20 @@ async fn test2() {
|
||||
let delete_term3 = Term::from_field_u64(a, 3u64);
|
||||
|
||||
let operations = vec![
|
||||
//UserOperation::Delete(delete_term1),
|
||||
UserOperation::Delete(delete_term1),
|
||||
UserOperation::Add(doc!(
|
||||
a => 1u64,
|
||||
b => "test1"
|
||||
b => "v1"
|
||||
)),
|
||||
//UserOperation::Delete(delete_term2),
|
||||
UserOperation::Delete(delete_term2),
|
||||
UserOperation::Add(doc!(
|
||||
a => 2u64,
|
||||
b => "test1"
|
||||
b => "v1"
|
||||
)),
|
||||
//UserOperation::Delete(delete_term3),
|
||||
UserOperation::Delete(delete_term3),
|
||||
UserOperation::Add(doc!(
|
||||
a => 3u64,
|
||||
b => "test1"
|
||||
b => "v1"
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -166,13 +165,10 @@ async fn test2() {
|
||||
index_writer.commit().unwrap();
|
||||
|
||||
let reader = index.reader().unwrap();
|
||||
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let tq = TermQuery::new(Term::from_field_u64(a, 3), IndexRecordOption::Basic);
|
||||
|
||||
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
|
||||
|
||||
assert!(docs.first().is_some());
|
||||
if let Some((_, doc_address)) = docs.first() {
|
||||
let old_doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
|
||||
|
||||
@@ -181,12 +177,14 @@ async fn test2() {
|
||||
if field == a {
|
||||
new_doc.add_field_value(a, value);
|
||||
}
|
||||
if field == b {
|
||||
assert_eq!(Some("v1"), value.as_str())
|
||||
}
|
||||
}
|
||||
new_doc.add_text(b, "test2");
|
||||
new_doc.add_text(b, "v2");
|
||||
|
||||
let delete_term = Term::from_field_u64(a, 3);
|
||||
index_writer.delete_term(delete_term);
|
||||
index_writer.commit().unwrap();
|
||||
index_writer.add_document(new_doc).unwrap();
|
||||
index_writer.commit().unwrap();
|
||||
}
|
||||
@@ -194,17 +192,14 @@ async fn test2() {
|
||||
reader.reload().unwrap();
|
||||
let searcher = reader.searcher();
|
||||
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
|
||||
|
||||
assert!(docs.first().is_some());
|
||||
if let Some((_, doc_address)) = docs.first() {
|
||||
let doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
|
||||
for (field, value) in doc.field_values() {
|
||||
if field == b {
|
||||
let value = value.as_str();
|
||||
println!("{:#?}", value);
|
||||
assert_eq!(Some("v2"), value.as_str())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("not found")
|
||||
}
|
||||
|
||||
let delete_term = Term::from_field_u64(a, 3);
|
||||
@@ -214,7 +209,7 @@ async fn test2() {
|
||||
reader.reload().unwrap();
|
||||
let searcher = reader.searcher();
|
||||
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
|
||||
|
||||
assert!(docs.first().is_none());
|
||||
if let Some((_, doc_address)) = docs.first() {
|
||||
let doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
|
||||
for (field, value) in doc.field_values() {
|
||||
@@ -223,7 +218,5 @@ async fn test2() {
|
||||
println!("{:#?}", value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("not found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -27,7 +26,7 @@ use crate::raise_error;
|
||||
use async_imap::types::Name;
|
||||
|
||||
pub async fn get_account_mailboxes(account_id: u64, remote: bool) -> BichonResult<Vec<MailBox>> {
|
||||
let account = AccountModel::check_account_active(account_id).await?;
|
||||
let account = AccountModel::check_account_exists(account_id).await?;
|
||||
if remote {
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
request_imap_all_mailbox_list(account_id).await
|
||||
@@ -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
src/modules/message/append.rs
Normal file
104
src/modules/message/append.rs
Normal 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(())
|
||||
}
|
||||
@@ -68,7 +68,7 @@ pub async fn retrieve_email_content(
|
||||
account_id: u64,
|
||||
id: u64,
|
||||
) -> BichonResult<FullMessageContent> {
|
||||
AccountModel::check_account_active(account_id).await?;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
let eml = EML_INDEX_MANAGER
|
||||
.get(account_id, id)
|
||||
.await?
|
||||
|
||||
@@ -33,7 +33,7 @@ pub async fn list_messages_impl(
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
AccountModel::check_account_active(account_id).await?;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
validate_pagination_params(page, page_size)?;
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.list_mailbox_envelopes(account_id, mailbox_id, page, page_size, true)
|
||||
@@ -62,7 +62,7 @@ pub async fn get_thread_messages(
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
AccountModel::check_account_active(account_id).await?;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.list_thread_envelopes(account_id, thread_id, page, page_size, true)
|
||||
.await
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/>.
|
||||
|
||||
|
||||
pub mod account;
|
||||
pub mod autoconfig;
|
||||
pub mod cache;
|
||||
@@ -27,6 +26,7 @@ pub mod database;
|
||||
pub mod envelope;
|
||||
pub mod error;
|
||||
pub mod imap;
|
||||
pub mod import;
|
||||
pub mod indexer;
|
||||
pub mod logger;
|
||||
pub mod mailbox;
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,24 +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::{HashMap, HashSet};
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
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::migration::AccountModel;
|
||||
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;
|
||||
|
||||
@@ -54,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?))
|
||||
}
|
||||
|
||||
@@ -71,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?)
|
||||
}
|
||||
|
||||
@@ -83,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))
|
||||
}
|
||||
|
||||
@@ -109,16 +110,14 @@ 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?)
|
||||
}
|
||||
|
||||
/// List accounts with optional pagination parameters
|
||||
#[oai(
|
||||
path = "/accounts",
|
||||
method = "get",
|
||||
operation_id = "list_accounts"
|
||||
)]
|
||||
#[oai(path = "/accounts", method = "get", operation_id = "list_accounts")]
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
/// Optional. The page number to retrieve (starting from 1).
|
||||
@@ -128,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
|
||||
@@ -172,7 +197,10 @@ impl AccountApi {
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<AccountRunningState>> {
|
||||
let account_id = account_id.0;
|
||||
context.require_account_access(account_id)?;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
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(),
|
||||
@@ -195,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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
52
src/modules/rest/api/import.rs
Normal file
52
src/modules/rest/api/import.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// 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::common::auth::ClientContext;
|
||||
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;
|
||||
|
||||
pub struct ImportApi;
|
||||
|
||||
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Import")]
|
||||
impl ImportApi {
|
||||
/// Batch import one or more EML files into a specified account and mail folder.
|
||||
///
|
||||
/// This endpoint accepts a JSON payload containing:
|
||||
/// - `account_id`: the target account to import emails into
|
||||
/// - `mail_folder`: the mailbox/folder name
|
||||
/// - `emls`: a list of base64-encoded .eml files
|
||||
///
|
||||
/// Returns a summary of the import result, including total processed, successful, and failed emails.
|
||||
#[oai(path = "/import", method = "post", operation_id = "do_batch_import")]
|
||||
async fn do_batch_import(
|
||||
&self,
|
||||
/// JSON payload with account info and EML files to import
|
||||
payload: Json<BatchEmlRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<BatchEmlResult>> {
|
||||
context
|
||||
.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
|
||||
.await?;
|
||||
Ok(Json(ImportEmls::do_import(payload.0).await?))
|
||||
}
|
||||
}
|
||||
@@ -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?))
|
||||
}
|
||||
|
||||
@@ -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 crate::modules::account::migration::AccountModel;
|
||||
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,71 +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;
|
||||
context.require_account_access(account_id)?;
|
||||
let id = id.0;
|
||||
let reader = EML_INDEX_MANAGER.get_reader(account_id, id).await?;
|
||||
AccountModel::check_account_exists(account_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;
|
||||
context.require_account_access(account_id)?;
|
||||
let email_id = id.0;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
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)
|
||||
@@ -194,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.
|
||||
@@ -204,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?;
|
||||
|
||||
@@ -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 access_token::AccessTokenApi;
|
||||
use account::AccountApi;
|
||||
use auto_config::AutoConfigApi;
|
||||
@@ -26,15 +25,20 @@ use oauth2::OAuth2Api;
|
||||
use poem_openapi::{OpenApiService, Tags};
|
||||
use system::SystemApi;
|
||||
|
||||
use crate::bichon_version;
|
||||
use crate::{
|
||||
bichon_version,
|
||||
modules::rest::api::{import::ImportApi, users::UsersApi},
|
||||
};
|
||||
|
||||
pub mod access_token;
|
||||
pub mod account;
|
||||
pub mod auto_config;
|
||||
pub mod import;
|
||||
pub mod mailbox;
|
||||
pub mod message;
|
||||
pub mod oauth2;
|
||||
pub mod system;
|
||||
pub mod users;
|
||||
|
||||
#[derive(Tags)]
|
||||
pub enum ApiTags {
|
||||
@@ -45,6 +49,8 @@ pub enum ApiTags {
|
||||
OAuth2,
|
||||
Message,
|
||||
System,
|
||||
Import,
|
||||
Users,
|
||||
}
|
||||
|
||||
type RustMailOpenApi = (
|
||||
@@ -55,6 +61,8 @@ type RustMailOpenApi = (
|
||||
MailBoxApi,
|
||||
OAuth2Api,
|
||||
MessageApi,
|
||||
ImportApi,
|
||||
UsersApi,
|
||||
);
|
||||
|
||||
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
|
||||
@@ -67,6 +75,8 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
|
||||
MailBoxApi,
|
||||
OAuth2Api,
|
||||
MessageApi,
|
||||
ImportApi,
|
||||
UsersApi,
|
||||
),
|
||||
"BichonApi",
|
||||
bichon_version!(),
|
||||
|
||||
@@ -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!(
|
||||
@@ -195,19 +234,19 @@ impl OAuth2Api {
|
||||
/// Configures an external OAuth2 token for a specified account.
|
||||
///
|
||||
/// This endpoint allows two usage modes:
|
||||
/// 1. If only an `access_token` is provided, RustMailer will store it directly.
|
||||
/// - In this mode, RustMailer **cannot refresh** the token, since it has no
|
||||
/// 1. If only an `access_token` is provided, Bichon will store it directly.
|
||||
/// - In this mode, Bichon **cannot refresh** the token, since it has no
|
||||
/// associated OAuth2 configuration or refresh token.
|
||||
/// - The caller is responsible for periodically updating the access token
|
||||
/// by calling this endpoint again.
|
||||
/// 2. If both `oauth2_id` and `refresh_token` are provided, it means the external
|
||||
/// OAuth2 authorization flow has been completed outside RustMailer.
|
||||
/// OAuth2 authorization flow has been completed outside Bichon.
|
||||
/// - Since the OAuth2 configuration (including client_id and client_secret)
|
||||
/// is already stored in RustMailer, the service can use the refresh token
|
||||
/// is already stored in Bichon, the service can use the refresh token
|
||||
/// to obtain new access tokens automatically.
|
||||
///
|
||||
/// Note: The `oauth2_id` must reference a valid OAuth2 configuration
|
||||
/// already created in RustMailer.
|
||||
/// already created in Bichon.
|
||||
#[oai(
|
||||
path = "/store-external-oauth2-token/:account_id",
|
||||
method = "post",
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
src/modules/rest/api/users.rs
Normal file
217
src/modules/rest/api/users.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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::common::error::ErrorCapture;
|
||||
use crate::modules::common::log::Tracing;
|
||||
use crate::modules::common::tls::rustls_config;
|
||||
@@ -33,13 +32,14 @@ use crate::modules::common::timeout::{Timeout, TIMEOUT_HEADER};
|
||||
use crate::raise_error;
|
||||
use api::create_openapi_service;
|
||||
use assets::FrontEndAssets;
|
||||
use http::HeaderValue;
|
||||
use http::{HeaderValue, Method};
|
||||
use poem::endpoint::EmbeddedFilesEndpoint;
|
||||
use poem::listener::{Listener, TcpListener};
|
||||
use poem::middleware::{CatchPanic, Compression, SetHeader};
|
||||
use poem::{endpoint::EmbeddedFileEndpoint, middleware::Cors, EndpointExt, Route, Server};
|
||||
use poem::{get, post};
|
||||
use public::oauth2::oauth2_callback;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod api;
|
||||
@@ -62,7 +62,7 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
};
|
||||
|
||||
let api_service = create_openapi_service()
|
||||
.summary("A self-hosted IMAP/SMTP middleware designed for developers");
|
||||
.summary("A lightweight, high-performance Rust email archiver with WebUI");
|
||||
|
||||
let swagger = api_service.swagger_ui();
|
||||
let redoc = api_service.redoc();
|
||||
@@ -78,10 +78,34 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.with(Timeout)
|
||||
.with(Tracing);
|
||||
|
||||
let mut cors_origins = SETTINGS.bichon_cors_origins.clone();
|
||||
if cors_origins.is_empty() {
|
||||
cors_origins = ["*".to_string()].into_iter().collect();
|
||||
}
|
||||
let cors_origins: Option<HashSet<String>> = SETTINGS.bichon_cors_origins.clone();
|
||||
|
||||
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
|
||||
|
||||
let cors = Cors::new()
|
||||
.allow_origins_fn(move |origin| {
|
||||
tracing::debug!("CORS: Incoming Origin = {:?}", origin);
|
||||
tracing::debug!("CORS: Configured origins = {:?}", cors_origins);
|
||||
if cors_origins.is_empty() {
|
||||
tracing::debug!("CORS: No origins configured, allowing all");
|
||||
return true;
|
||||
}
|
||||
cors_origins.iter().any(|o| o == origin)
|
||||
})
|
||||
//.allow_origins(cors_origins)
|
||||
.allow_credentials(true)
|
||||
.allow_methods(&[
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::DELETE,
|
||||
Method::OPTIONS,
|
||||
Method::HEAD,
|
||||
Method::PATCH,
|
||||
])
|
||||
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
|
||||
.expose_headers(vec!["Accept"])
|
||||
.max_age(SETTINGS.bichon_cors_max_age);
|
||||
|
||||
let cache_static = || {
|
||||
SetHeader::new().overriding(
|
||||
@@ -90,14 +114,6 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
)
|
||||
};
|
||||
|
||||
let cors = Cors::new()
|
||||
.allow_origins(cors_origins)
|
||||
.allow_credentials(true)
|
||||
.allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"])
|
||||
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
|
||||
.expose_headers(vec!["Accept"])
|
||||
.max_age(SETTINGS.bichon_cors_max_age);
|
||||
|
||||
let route = Route::new()
|
||||
.nest("/api-docs/swagger", swagger)
|
||||
.nest("/api-docs/redoc", redoc)
|
||||
@@ -122,7 +138,7 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.with(CatchPanic::new());
|
||||
|
||||
let server = Server::new(listener)
|
||||
.name("RustMailer API Service")
|
||||
.name("Bichon Service")
|
||||
.idle_timeout(Duration::from_secs(60))
|
||||
.run_with_graceful_shutdown(
|
||||
route.catch_all_error(error_handler),
|
||||
@@ -130,7 +146,7 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
Some(Duration::from_secs(5)),
|
||||
);
|
||||
println!(
|
||||
"RustMailer API Service is now running on port {}.",
|
||||
"Bichon Service is now running on port {}.",
|
||||
SETTINGS.bichon_http_port
|
||||
);
|
||||
server
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +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 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(
|
||||
@@ -77,7 +76,6 @@ pub struct Settings {
|
||||
/// CORS allowed origins (default: "*")
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "http://localhost:5173, http://localhost:15630, *",
|
||||
env,
|
||||
help = "Set the allowed CORS origins (comma-separated list, e.g., \"https://example.com, https://another.com\")",
|
||||
value_parser = ValueParser::new(|s: &str| -> Result<HashSet<String>, String> {
|
||||
@@ -88,7 +86,7 @@ pub struct Settings {
|
||||
Ok(set)
|
||||
})
|
||||
)]
|
||||
pub bichon_cors_origins: HashSet<String>,
|
||||
pub bichon_cors_origins: Option<HashSet<String>>,
|
||||
|
||||
/// CORS max age in seconds (default: 86400)
|
||||
#[clap(
|
||||
@@ -134,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,
|
||||
@@ -176,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.
|
||||
@@ -219,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")]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,110 +16,109 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// use crate::{
|
||||
// decrypt, encrypt, generate_token,
|
||||
// modules::{
|
||||
// error::{code::ErrorCode, BichonResult},
|
||||
// settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
|
||||
// },
|
||||
// raise_error,
|
||||
// };
|
||||
// use std::fs::File;
|
||||
// use std::io::Write;
|
||||
|
||||
use crate::{
|
||||
decrypt, encrypt, generate_token,
|
||||
modules::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
// pub const ROOT_TOKEN: &str = "root-token";
|
||||
// pub const ROOT_PASSWORD: &str = "root-password";
|
||||
// pub const DEFAULT_ROOT_PASSWORD: &str = "root";
|
||||
// pub const ROOT_TOKEN_FILE: &str = "root";
|
||||
|
||||
pub const ROOT_TOKEN: &str = "root-token";
|
||||
pub const ROOT_PASSWORD: &str = "root-password";
|
||||
pub const DEFAULT_ROOT_PASSWORD: &str = "root";
|
||||
pub const ROOT_TOKEN_FILE: &str = "root";
|
||||
// async fn get_or_generate(
|
||||
// key: &str,
|
||||
// generate: impl Fn() -> String,
|
||||
// save_file_name: Option<&str>,
|
||||
// force: bool,
|
||||
// ) -> BichonResult<String> {
|
||||
// if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
|
||||
// if force {
|
||||
// // If force is true, write the existing value to the file
|
||||
// if let Some(filename) = save_file_name {
|
||||
// save_to_file(&existing_value.to_string(), filename).await?;
|
||||
// }
|
||||
// }
|
||||
// Ok(existing_value)
|
||||
// } else {
|
||||
// // If no value exists, generate a new value
|
||||
// let new_value = generate();
|
||||
// SystemSetting::set_value(key, new_value.clone()).await?;
|
||||
|
||||
async fn get_or_generate(
|
||||
key: &str,
|
||||
generate: impl Fn() -> String,
|
||||
save_file_name: Option<&str>,
|
||||
force: bool,
|
||||
) -> BichonResult<String> {
|
||||
if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
|
||||
if force {
|
||||
// If force is true, write the existing value to the file
|
||||
if let Some(filename) = save_file_name {
|
||||
save_to_file(&existing_value.to_string(), filename).await?;
|
||||
}
|
||||
}
|
||||
Ok(existing_value)
|
||||
} else {
|
||||
// If no value exists, generate a new value
|
||||
let new_value = generate();
|
||||
SystemSetting::set_value(key, new_value.clone()).await?;
|
||||
// // Write the new value to the file, if specified
|
||||
// if let Some(filename) = save_file_name {
|
||||
// save_to_file(&new_value.to_string(), filename).await?;
|
||||
// }
|
||||
// Ok(new_value)
|
||||
// }
|
||||
// }
|
||||
|
||||
// Write the new value to the file, if specified
|
||||
if let Some(filename) = save_file_name {
|
||||
save_to_file(&new_value.to_string(), filename).await?;
|
||||
}
|
||||
Ok(new_value)
|
||||
}
|
||||
}
|
||||
// pub async fn ensure_root_token() -> BichonResult<()> {
|
||||
// get_or_generate(
|
||||
// ROOT_TOKEN,
|
||||
// || generate_token!(128),
|
||||
// Some(ROOT_TOKEN_FILE),
|
||||
// true,
|
||||
// )
|
||||
// .await?;
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
pub async fn ensure_root_token() -> BichonResult<()> {
|
||||
get_or_generate(
|
||||
ROOT_TOKEN,
|
||||
|| generate_token!(128),
|
||||
Some(ROOT_TOKEN_FILE),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
// pub async fn reset_root_token() -> BichonResult<String> {
|
||||
// let new_token = generate_token!(128);
|
||||
// save_new_token(&new_token).await?;
|
||||
// save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
|
||||
// Ok(new_token)
|
||||
// }
|
||||
|
||||
pub async fn reset_root_token() -> BichonResult<String> {
|
||||
let new_token = generate_token!(128);
|
||||
save_new_token(&new_token).await?;
|
||||
save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
|
||||
Ok(new_token)
|
||||
}
|
||||
// async fn save_new_token(token: &str) -> BichonResult<()> {
|
||||
// let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
|
||||
// setting.set().await
|
||||
// }
|
||||
|
||||
async fn save_new_token(token: &str) -> BichonResult<()> {
|
||||
let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
|
||||
setting.set().await
|
||||
}
|
||||
// async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
|
||||
// let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
|
||||
// let mut file = File::create(&file_path)
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
// writeln!(file, "{}", content)
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
|
||||
let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
|
||||
let mut file = File::create(&file_path)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
writeln!(file, "{}", content)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(())
|
||||
}
|
||||
// pub fn check_root_password(password: &str) -> BichonResult<String> {
|
||||
// let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
|
||||
// let matched = match stored_encrypted_password {
|
||||
// Some(ref stored) => {
|
||||
// let decrypted = decrypt!(stored)?;
|
||||
// decrypted == password
|
||||
// }
|
||||
// None => DEFAULT_ROOT_PASSWORD == password,
|
||||
// };
|
||||
|
||||
pub fn check_root_password(password: &str) -> BichonResult<String> {
|
||||
let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
|
||||
let matched = match stored_encrypted_password {
|
||||
Some(ref stored) => {
|
||||
let decrypted = decrypt!(stored)?;
|
||||
decrypted == password
|
||||
}
|
||||
None => DEFAULT_ROOT_PASSWORD == password,
|
||||
};
|
||||
// if !matched {
|
||||
// return Err(raise_error!(
|
||||
// "Invalid password".into(),
|
||||
// ErrorCode::PermissionDenied
|
||||
// ));
|
||||
// }
|
||||
|
||||
if !matched {
|
||||
return Err(raise_error!(
|
||||
"Invalid password".into(),
|
||||
ErrorCode::PermissionDenied
|
||||
));
|
||||
}
|
||||
// let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
|
||||
// raise_error!(
|
||||
// "Root token not found — this should never happen".into(),
|
||||
// ErrorCode::InternalError
|
||||
// )
|
||||
// })?;
|
||||
|
||||
let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Root token not found — this should never happen".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
// Ok(root_token)
|
||||
// }
|
||||
|
||||
Ok(root_token)
|
||||
}
|
||||
|
||||
pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
|
||||
let encrypted_password = encrypt!(new_password)?;
|
||||
SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
|
||||
}
|
||||
// pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
|
||||
// let encrypted_password = encrypt!(new_password)?;
|
||||
// SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
|
||||
// }
|
||||
|
||||
44
src/modules/token/view.rs
Normal file
44
src/modules/token/view.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::token::TokenType;
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
|
||||
pub struct AccessTokenResp {
|
||||
pub user_id: u64,
|
||||
pub token: String,
|
||||
/// An optional name of the token.
|
||||
pub name: Option<String>,
|
||||
/// Token type: WebUI or API
|
||||
pub token_type: TokenType,
|
||||
/// The timestamp (in milliseconds since epoch) when the token was created.
|
||||
pub created_at: i64,
|
||||
/// The timestamp (in milliseconds since epoch) when the token was last updated.
|
||||
pub updated_at: i64,
|
||||
/// The timestamp (in milliseconds since epoch) when the token expires.
|
||||
/// None means the token does not expire (this applies only to API tokens).
|
||||
pub expire_at: Option<i64>,
|
||||
/// The timestamp (in milliseconds since epoch) when the token was last used.
|
||||
pub last_access_at: i64,
|
||||
|
||||
pub user_name: String,
|
||||
pub user_email: String,
|
||||
}
|
||||
73
src/modules/users/acl.rs
Normal file
73
src/modules/users/acl.rs
Normal 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
src/modules/users/minimal.rs
Normal file
49
src/modules/users/minimal.rs
Normal 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
src/modules/users/mod.rs
Normal file
695
src/modules/users/mod.rs
Normal 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
src/modules/users/payload.rs
Normal file
453
src/modules/users/payload.rs
Normal 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
src/modules/users/permissions.rs
Normal file
239
src/modules/users/permissions.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeSet, HashSet},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
users::role::RoleType,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
pub static VALID_PERMISSION_SET: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
Permission::all_permissions()
|
||||
.into_iter()
|
||||
.map(|(key, _)| key)
|
||||
.collect()
|
||||
});
|
||||
|
||||
pub struct Permission;
|
||||
|
||||
impl Permission {
|
||||
// ----------------------------------------------------------------------
|
||||
// 1. Global Management Permissions (System, Users, Tokens)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// Basic platform access. Required for any user to log in and access the dashboard.
|
||||
/// This provides no administrative powers.
|
||||
pub const SYSTEM_ACCESS: &str = "system:access";
|
||||
|
||||
/// Manage core system configurations (OAuth Client ID/Secret, Proxy settings).
|
||||
pub const ROOT: &str = "system:root";
|
||||
|
||||
/// Create, modify, and delete all users and their roles (Admin only).
|
||||
pub const USER_MANAGE: &str = "user:manage";
|
||||
|
||||
/// View the minimal user list and basic profiles (Managers and Admins).
|
||||
pub const USER_VIEW: &str = "user:view";
|
||||
|
||||
/// View and revoke all access tokens in the system.
|
||||
pub const TOKEN_MANAGE: &str = "token:manage";
|
||||
|
||||
/// Create new email account connections.
|
||||
pub const ACCOUNT_CREATE: &str = "account:create";
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 2. Global "ALL" Scoped Permissions (Reserved for Admin)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// Manage configuration for all accounts (Global control).
|
||||
pub const ACCOUNT_MANAGE_ALL: &str = "account:manage:all";
|
||||
|
||||
/// Read mail data from all accounts (Search, view messages).
|
||||
pub const DATA_READ_ALL: &str = "data:read:all";
|
||||
|
||||
/// Download raw EML/MIME files from all accounts.
|
||||
pub const DATA_RAW_DOWNLOAD_ALL: &str = "data:raw:download:all";
|
||||
|
||||
/// Delete messages from all accounts.
|
||||
pub const DATA_DELETE_ALL: &str = "data:delete:all";
|
||||
|
||||
/// Manage metadata (e.g., tags, categories, notes) for messages in ALL email accounts.
|
||||
pub const DATA_MANAGE_ALL: &str = "data:manage:all";
|
||||
|
||||
/// Export messages in batches from all accounts.
|
||||
pub const DATA_EXPORT_BATCH_ALL: &str = "data:export:batch:all";
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 3. Scoped/Limited Permissions (Manager & Viewer)
|
||||
// Authorization requires checking the user's Account Access List (ACL)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// Manage (modify/delete/sync) configuration for a specific set of accounts.
|
||||
pub const ACCOUNT_MANAGE: &str = "account:manage";
|
||||
|
||||
/// Read details and sync status for a specific set of accounts.
|
||||
pub const ACCOUNT_READ_DETAILS: &str = "account:read_details";
|
||||
|
||||
/// Manage mail data metadata (e.g., updating tags, adding notes)
|
||||
/// for specific accounts.
|
||||
pub const DATA_MANAGE: &str = "data:manage";
|
||||
|
||||
/// Read mail data (Search, view) from a specific set of accounts.
|
||||
pub const DATA_READ: &str = "data:read";
|
||||
|
||||
/// Download raw EML/MIME files from a specific set of accounts.
|
||||
pub const DATA_RAW_DOWNLOAD: &str = "data:raw:download";
|
||||
|
||||
/// Delete messages from a specific set of accounts.
|
||||
pub const DATA_DELETE: &str = "data:delete";
|
||||
|
||||
/// Export messages in batches from a specific set of accounts.
|
||||
pub const DATA_EXPORT_BATCH: &str = "data:export:batch";
|
||||
|
||||
/// Import EML/PST data into a SPECIFIC account.
|
||||
/// Authorization requires checking access to the target account_id.
|
||||
pub const DATA_IMPORT_BATCH: &str = "data:import:batch";
|
||||
|
||||
pub fn global_permissions() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
(
|
||||
Self::SYSTEM_ACCESS,
|
||||
"Basic platform access for dashboard and personal settings.",
|
||||
),
|
||||
(Self::ROOT, "Full system access and configuration."),
|
||||
(Self::USER_MANAGE, "Create, update, and delete users."),
|
||||
(
|
||||
Self::USER_VIEW,
|
||||
"Read-only access to user list and profiles.",
|
||||
),
|
||||
(Self::TOKEN_MANAGE, "View and revoke all active API tokens."),
|
||||
(
|
||||
Self::ACCOUNT_CREATE,
|
||||
"Connect new email accounts to the system.",
|
||||
),
|
||||
(
|
||||
Self::ACCOUNT_MANAGE_ALL,
|
||||
"Manage configurations for all email accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_READ_ALL,
|
||||
"Search and read messages across all accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_MANAGE_ALL,
|
||||
"Manage metadata and tags for all accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_RAW_DOWNLOAD_ALL,
|
||||
"Download raw EML data from any account.",
|
||||
),
|
||||
(
|
||||
Self::DATA_DELETE_ALL,
|
||||
"Permanently delete messages from any account.",
|
||||
),
|
||||
(
|
||||
Self::DATA_EXPORT_BATCH_ALL,
|
||||
"Export bulk message data from all accounts.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn account_permissions() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
(
|
||||
Self::ACCOUNT_MANAGE,
|
||||
"Update or sync settings for authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::ACCOUNT_READ_DETAILS,
|
||||
"View status and details of authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_READ,
|
||||
"Read messages from authorized email accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_MANAGE,
|
||||
"Manage tags and metadata for authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_RAW_DOWNLOAD,
|
||||
"Download raw EML files from authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_DELETE,
|
||||
"Delete messages from authorized email accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_EXPORT_BATCH,
|
||||
"Export messages from authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_IMPORT_BATCH,
|
||||
"Import external EML/PST data into authorized accounts.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_permissions() -> Vec<(&'static str, &'static str)> {
|
||||
let mut all = Self::global_permissions();
|
||||
all.extend(Self::account_permissions());
|
||||
all
|
||||
}
|
||||
|
||||
fn is_account_permission(perm: &str) -> bool {
|
||||
Self::account_permissions().iter().any(|(p, _)| *p == perm)
|
||||
}
|
||||
|
||||
fn is_global_permission(perm: &str) -> bool {
|
||||
Self::global_permissions().iter().any(|(p, _)| *p == perm)
|
||||
}
|
||||
|
||||
pub fn validate_role_permissions(
|
||||
role_type: &RoleType,
|
||||
permissions: &BTreeSet<String>,
|
||||
) -> BichonResult<()> {
|
||||
for p in permissions {
|
||||
match role_type {
|
||||
RoleType::Global => {
|
||||
if !Self::is_global_permission(p) {
|
||||
return Err(raise_error!(
|
||||
format!("Permission '{}' is not a valid Global permission", p),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
RoleType::Account => {
|
||||
if !Self::is_account_permission(p) {
|
||||
return Err(raise_error!(
|
||||
format!("Permission '{}' is not a valid Account permission", p),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
359
src/modules/users/role.rs
Normal file
359
src/modules/users/role.rs
Normal file
@@ -0,0 +1,359 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeSet, HashSet},
|
||||
fmt::{self, Display},
|
||||
};
|
||||
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
id,
|
||||
modules::{
|
||||
database::{
|
||||
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
|
||||
update_impl, with_transaction,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
users::{
|
||||
payload::{RoleCreateRequest, RoleUpdateRequest},
|
||||
permissions::*,
|
||||
},
|
||||
},
|
||||
raise_error, utc_now,
|
||||
};
|
||||
|
||||
/// Enumerates the built-in roles in the Bichon system.
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub enum BuiltinRole {
|
||||
Admin,
|
||||
Manager,
|
||||
Member,
|
||||
AccountManager,
|
||||
AccountViewer,
|
||||
}
|
||||
|
||||
impl Display for BuiltinRole {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match self {
|
||||
BuiltinRole::Admin => "admin",
|
||||
BuiltinRole::Manager => "manager",
|
||||
BuiltinRole::Member => "member",
|
||||
BuiltinRole::AccountManager => "account_manager",
|
||||
BuiltinRole::AccountViewer => "account_viewer",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
impl BuiltinRole {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
BuiltinRole::Admin => {
|
||||
"Full system administrator with unrestricted access to all accounts, user management, and system configurations."
|
||||
}
|
||||
BuiltinRole::Manager => {
|
||||
"Standard operational manager. Can manage users, create accounts, and perform data operations on authorized email accounts."
|
||||
}
|
||||
BuiltinRole::Member => {
|
||||
"Regular platform member. Provides basic login access to the system without any administrative or global management privileges."
|
||||
}
|
||||
BuiltinRole::AccountManager => {
|
||||
"Specific account manager. Has full administrative control over a particular email account, including configuration and data deletion."
|
||||
}
|
||||
BuiltinRole::AccountViewer => {
|
||||
"Specific account observer. Has read-only access to messages and metadata for a particular email account."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the set of static permissions associated with the role.
|
||||
pub fn get_permissions(&self) -> HashSet<&'static str> {
|
||||
match self {
|
||||
BuiltinRole::Admin => Self::admin_permissions(),
|
||||
BuiltinRole::Manager => Self::manager_permissions(),
|
||||
BuiltinRole::Member => Self::member_permissions(),
|
||||
BuiltinRole::AccountManager => Self::account_owner_permissions(),
|
||||
BuiltinRole::AccountViewer => Self::account_viewer_permissions(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin Role: Full control over the system and all data.
|
||||
fn admin_permissions() -> HashSet<&'static str> {
|
||||
[
|
||||
// System-Wide
|
||||
Permission::ROOT,
|
||||
Permission::USER_MANAGE,
|
||||
Permission::USER_VIEW,
|
||||
Permission::TOKEN_MANAGE,
|
||||
// Account Configuration
|
||||
Permission::ACCOUNT_CREATE,
|
||||
Permission::ACCOUNT_MANAGE_ALL, // Global account management
|
||||
// Data Access (Global ALL)
|
||||
Permission::DATA_READ_ALL,
|
||||
Permission::DATA_MANAGE_ALL,
|
||||
Permission::DATA_RAW_DOWNLOAD_ALL,
|
||||
Permission::DATA_DELETE_ALL,
|
||||
Permission::DATA_EXPORT_BATCH_ALL,
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Manager Role: Data and account configuration management, limited user management.
|
||||
/// ALL data/account access must be scoped by the user's ACL.
|
||||
fn manager_permissions() -> HashSet<&'static str> {
|
||||
[Permission::USER_VIEW, Permission::ACCOUNT_CREATE]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn member_permissions() -> HashSet<&'static str> {
|
||||
[Permission::SYSTEM_ACCESS].into_iter().collect()
|
||||
}
|
||||
|
||||
fn account_owner_permissions() -> HashSet<&'static str> {
|
||||
[
|
||||
Permission::ACCOUNT_MANAGE,
|
||||
Permission::ACCOUNT_READ_DETAILS,
|
||||
Permission::DATA_READ,
|
||||
Permission::DATA_MANAGE,
|
||||
Permission::DATA_RAW_DOWNLOAD,
|
||||
Permission::DATA_DELETE,
|
||||
Permission::DATA_EXPORT_BATCH,
|
||||
Permission::DATA_IMPORT_BATCH,
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn account_viewer_permissions() -> HashSet<&'static str> {
|
||||
[Permission::ACCOUNT_READ_DETAILS, Permission::DATA_READ]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// Global Roles (Starting with 1)
|
||||
pub const DEFAULT_ADMIN_ROLE_ID: u64 = 100_000_000_000_000; // System Admin
|
||||
pub const DEFAULT_MANAGER_ROLE_ID: u64 = 100_100_000_000_000; // System Manager
|
||||
pub const DEFAULT_MEMBER_ROLE_ID: u64 = 100_200_000_000_000; // Regular Member (system:access)
|
||||
|
||||
// Account-specific Roles (Starting with 2)
|
||||
pub const DEFAULT_ACCOUNT_MANAGER_ROLE_ID: u64 = 200_100_000_000_000;
|
||||
pub const DEFAULT_ACCOUNT_VIEWER_ROLE_ID: u64 = 200_200_000_000_000;
|
||||
|
||||
fn is_builtin(id: u64) -> bool {
|
||||
matches!(
|
||||
id,
|
||||
DEFAULT_ADMIN_ROLE_ID
|
||||
| DEFAULT_MANAGER_ROLE_ID
|
||||
| DEFAULT_MEMBER_ROLE_ID
|
||||
| DEFAULT_ACCOUNT_MANAGER_ROLE_ID
|
||||
| DEFAULT_ACCOUNT_VIEWER_ROLE_ID
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
|
||||
pub enum RoleType {
|
||||
#[default]
|
||||
Global,
|
||||
Account,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
#[native_model(id = 9, version = 1)]
|
||||
#[native_db]
|
||||
pub struct UserRole {
|
||||
#[primary_key]
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub permissions: BTreeSet<String>,
|
||||
pub is_builtin: bool,
|
||||
pub created_at: i64,
|
||||
pub role_type: RoleType,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
pub async fn ensure_default_roles_exists() -> BichonResult<()> {
|
||||
let builtin_roles = vec![
|
||||
(BuiltinRole::Admin, DEFAULT_ADMIN_ROLE_ID, RoleType::Global),
|
||||
(
|
||||
BuiltinRole::Manager,
|
||||
DEFAULT_MANAGER_ROLE_ID,
|
||||
RoleType::Global,
|
||||
),
|
||||
(
|
||||
BuiltinRole::Member,
|
||||
DEFAULT_MEMBER_ROLE_ID,
|
||||
RoleType::Global,
|
||||
),
|
||||
(
|
||||
BuiltinRole::AccountManager,
|
||||
DEFAULT_ACCOUNT_MANAGER_ROLE_ID,
|
||||
RoleType::Account,
|
||||
),
|
||||
(
|
||||
BuiltinRole::AccountViewer,
|
||||
DEFAULT_ACCOUNT_VIEWER_ROLE_ID,
|
||||
RoleType::Account,
|
||||
),
|
||||
];
|
||||
|
||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
||||
let now = utc_now!();
|
||||
|
||||
for (role, role_id, role_type) in builtin_roles {
|
||||
let exists = rw
|
||||
.get()
|
||||
.primary::<UserRole>(role_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.is_some();
|
||||
|
||||
if !exists {
|
||||
let permissions: BTreeSet<String> = role
|
||||
.get_permissions()
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
rw.insert(UserRole {
|
||||
id: role_id,
|
||||
name: role.to_string(),
|
||||
description: Some(role.description().to_string()),
|
||||
permissions,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_builtin: true,
|
||||
role_type,
|
||||
})
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_all() -> BichonResult<Vec<UserRole>> {
|
||||
list_all_impl(DB_MANAGER.meta_db()).await
|
||||
}
|
||||
|
||||
pub async fn find(role_id: u64) -> BichonResult<Option<UserRole>> {
|
||||
async_find_impl(DB_MANAGER.meta_db(), role_id).await
|
||||
}
|
||||
|
||||
pub async fn create(request: RoleCreateRequest) -> BichonResult<UserRole> {
|
||||
let _ = &request.validate().await?;
|
||||
let now = utc_now!();
|
||||
let new_role = UserRole {
|
||||
id: id!(64),
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
permissions: request.permissions,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_builtin: false,
|
||||
role_type: request.role_type,
|
||||
};
|
||||
insert_impl(DB_MANAGER.meta_db(), new_role.clone()).await?;
|
||||
Ok(new_role)
|
||||
}
|
||||
|
||||
pub async fn update(id: u64, request: RoleUpdateRequest) -> BichonResult<()> {
|
||||
if is_builtin(id) && request.permissions.is_some() {
|
||||
return Err(raise_error!(
|
||||
"The permissions of a builtin role are immutable. Please create a custom role instead.".into(),
|
||||
ErrorCode::Forbidden
|
||||
));
|
||||
}
|
||||
let _ = &request.validate().await?;
|
||||
|
||||
if let Some(permissions) = &request.permissions {
|
||||
let role = Self::find(id).await?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UserRole with id={} not found", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
Permission::validate_role_permissions(&role.role_type, permissions)?;
|
||||
}
|
||||
|
||||
update_impl(
|
||||
DB_MANAGER.meta_db(),
|
||||
move |rw| {
|
||||
rw.get()
|
||||
.primary::<UserRole>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UserRole with id={} not found", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
},
|
||||
move |current| {
|
||||
let mut updated = current.clone();
|
||||
if let Some(name) = request.name {
|
||||
updated.name = name;
|
||||
}
|
||||
|
||||
if let Some(desc) = request.description {
|
||||
updated.description = Some(desc);
|
||||
}
|
||||
|
||||
if let Some(permissions) = request.permissions {
|
||||
updated.permissions = permissions;
|
||||
}
|
||||
updated.updated_at = utc_now!();
|
||||
Ok(updated)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(id: u64) -> BichonResult<()> {
|
||||
if is_builtin(id) {
|
||||
return Err(raise_error!(
|
||||
format!("Cannot delete a default system role (ID: {}).", id),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
delete_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get()
|
||||
.primary::<UserRole>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UserRole '{}' not found during deletion process.", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
54
src/modules/users/view.rs
Normal file
54
src/modules/users/view.rs
Normal 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>,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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::settings::proxy::Proxy;
|
||||
use crate::modules::utils::tls::establish_tls_stream;
|
||||
@@ -56,17 +55,19 @@ pub(crate) async fn establish_tcp_connection_with_timeout(
|
||||
Ok(Box::pin(timeout_stream))
|
||||
}
|
||||
|
||||
pub(crate) async fn establish_tls_connection(
|
||||
pub async fn establish_tls_connection(
|
||||
address: SocketAddr,
|
||||
server_hostname: &str,
|
||||
alpn_protocols: &[&str],
|
||||
use_proxy: Option<u64>,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<impl SessionStream> {
|
||||
// Establish the TCP connection with timeout
|
||||
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
|
||||
|
||||
// Wrap the TCP stream with TLS encryption
|
||||
let tls_stream = establish_tls_stream(server_hostname, alpn_protocols, tcp_stream).await?;
|
||||
let tls_stream =
|
||||
establish_tls_stream(server_hostname, alpn_protocols, tcp_stream, dangerous).await?;
|
||||
|
||||
// Return the TLS stream wrapped in a SessionStream
|
||||
Ok(tls_stream)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
@@ -24,38 +23,44 @@ use crate::{
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use rustls::RootCertStore;
|
||||
use rustls::{
|
||||
client::danger::{ServerCertVerified, ServerCertVerifier},
|
||||
RootCertStore,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn establish_tls_stream(
|
||||
server_hostname: &str,
|
||||
alpn_protocols: &[&str],
|
||||
stream: impl SessionStream + 'static,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<impl SessionStream> {
|
||||
let tls_stream = establish_rustls_stream(server_hostname, alpn_protocols, stream).await?;
|
||||
let tls_stream =
|
||||
establish_rustls_stream(server_hostname, alpn_protocols, stream, dangerous).await?;
|
||||
let boxed_stream: Box<dyn SessionStream> = Box::new(tls_stream);
|
||||
Ok(boxed_stream)
|
||||
}
|
||||
|
||||
pub async fn establish_rustls_stream(
|
||||
async fn establish_rustls_stream(
|
||||
server_hostname: &str,
|
||||
alpn_protocols: &[&str],
|
||||
stream: impl SessionStream,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<impl SessionStream> {
|
||||
// Create a root certificate store and add default trusted roots
|
||||
let root_store = RootCertStore {
|
||||
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
|
||||
};
|
||||
|
||||
// Configure the Rustls client with the root certs and no client authentication
|
||||
let mut config = rustls::ClientConfig::builder()
|
||||
//builder_with_provider(
|
||||
// rustls::crypto::ring::default_provider().into(),
|
||||
// )
|
||||
// .with_protocol_versions(&[&rustls::version::TLS13])
|
||||
// .unwrap()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
let mut config = if dangerous {
|
||||
rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(SkipCertVerification))
|
||||
.with_no_client_auth()
|
||||
} else {
|
||||
rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth()
|
||||
};
|
||||
|
||||
// Set the ALPN protocols
|
||||
config.alpn_protocols = alpn_protocols
|
||||
@@ -76,3 +81,56 @@ pub async fn establish_rustls_stream(
|
||||
|
||||
Ok(tls_stream)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SkipCertVerification;
|
||||
|
||||
impl ServerCertVerifier for SkipCertVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
// Always return a valid certificate verification result
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA1,
|
||||
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::ED448,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@mui/x-tree-view": "^8.19.0",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
@@ -37,6 +41,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||
"@react-spring/web": "^10.0.3",
|
||||
"@tabler/icons-react": "^3.24.0",
|
||||
"@tanstack/react-query": "^5.62.3",
|
||||
"@tanstack/react-router": "^1.86.1",
|
||||
@@ -50,13 +55,16 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"handlebars": "^4.7.8",
|
||||
"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",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.14.1",
|
||||
|
||||
2481
web/pnpm-lock.yaml
generated
2481
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -51,7 +51,7 @@ export interface OAuth2Tokens {
|
||||
access_token: string;
|
||||
account_id: string;
|
||||
created_at: number;
|
||||
oauth2_name: string;
|
||||
oauth2_id: number;
|
||||
refresh_token: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
|
||||
import axiosInstance from "@/api/axiosInstance";
|
||||
import { Proxy } from "@/features/settings/proxy/data/schema";
|
||||
|
||||
export interface Release {
|
||||
tag_name: string;
|
||||
@@ -54,6 +53,8 @@ export interface DashboardStats {
|
||||
with_attachment_count: number; // Emails with attachments
|
||||
without_attachment_count: number; // Emails without attachments
|
||||
top_largest_emails: LargestEmail[]; // Top 10 largest emails
|
||||
system_version: string, //The semantic version string of the currently running backend service
|
||||
commit_hash: string //Git commit hash used to build this system version
|
||||
}
|
||||
|
||||
export interface TimeBucket {
|
||||
@@ -71,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;
|
||||
@@ -102,4 +131,10 @@ export const add_proxy = async (url: string) => {
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const get_system_configurations = async () => {
|
||||
const response = await axiosInstance.get<ServerConfigurations>(`/api/v1/system-configurations`);
|
||||
return response.data;
|
||||
};
|
||||
208
web/src/api/users/api.ts
Normal file
208
web/src/api/users/api.ts
Normal 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;
|
||||
};
|
||||
@@ -21,16 +21,19 @@ import { Card } from "@/components/ui/card";
|
||||
import { FixedHeader } from "./layout/fixed-header";
|
||||
import { Main } from "./layout/main";
|
||||
import Logo from '@/assets/logo.svg'
|
||||
|
||||
const docsOptions = [
|
||||
{ name: "Swagger UI", path: "/api-docs/swagger" },
|
||||
{ name: "ReDoc", path: "/api-docs/redoc" },
|
||||
{ name: "OpenAPI Explorer", path: "/api-docs/explorer" },
|
||||
{ name: "Scalar", path: "/api-docs/scalar" },
|
||||
{ name: "Download Spec YAML", path: "/api-docs/spec.yaml" }
|
||||
];
|
||||
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" },
|
||||
{ name: t('apiDocs.openAPIExplorer'), path: "/api-docs/explorer" },
|
||||
{ name: t('apiDocs.scalar'), path: "/api-docs/scalar" },
|
||||
{ name: t('apiDocs.downloadSpecYAML'), path: "/api-docs/spec.yaml" }
|
||||
];
|
||||
const handleCardClick = (path: string) => {
|
||||
// Open in new tab
|
||||
window.open(path, '_blank', 'noopener,noreferrer');
|
||||
@@ -43,12 +46,13 @@ export default function APIDocs() {
|
||||
<Main>
|
||||
<div className='mb-2 flex items-center justify-between space-y-2 flex-wrap gap-x-4'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>API Documentation</h2>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>{t('navigation.apiDocs')}</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
Choose your preferred API documentation type
|
||||
{t('apiDocs.choosePreferredType')}
|
||||
</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">
|
||||
@@ -62,7 +66,7 @@ export default function APIDocs() {
|
||||
<img
|
||||
src={Logo}
|
||||
className="max-h-[66px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="RustMailer Logo"
|
||||
alt="Bichon Logo"
|
||||
/>
|
||||
<h3 className="text-sm font-medium">{option.name}</h3>
|
||||
</div>
|
||||
|
||||
@@ -35,13 +35,16 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import { sidebarData } from './layout/data/sidebar-data'
|
||||
import { useSidebarData } from './layout/data/sidebar-data'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function CommandMenu() {
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
const sidebarData = useSidebarData()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
@@ -53,10 +56,10 @@ export function CommandMenu() {
|
||||
|
||||
return (
|
||||
<CommandDialog modal open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder='Type a command or search...' />
|
||||
<CommandInput placeholder={t('command.typeCommandOrSearch')} />
|
||||
<CommandList>
|
||||
<ScrollArea type='hover' className='h-72 pr-1'>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandEmpty>{t('command.noResultsFound')}</CommandEmpty>
|
||||
{sidebarData.navGroups.map((group) => (
|
||||
<CommandGroup key={group.title} heading={group.title}>
|
||||
{group.items.map((navItem, i) => {
|
||||
@@ -94,13 +97,13 @@ export function CommandMenu() {
|
||||
</CommandGroup>
|
||||
))}
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading='Theme'>
|
||||
<CommandGroup heading={t('command.theme')}>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
|
||||
<IconSun /> <span>Light</span>
|
||||
<IconSun /> <span>{t('command.light')}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
|
||||
<IconMoon className='scale-90' />
|
||||
<span>Dark</span>
|
||||
<span>{t('command.dark')}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
} 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
|
||||
@@ -45,6 +47,7 @@ interface ConfirmDialogProps {
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
title,
|
||||
desc,
|
||||
@@ -70,14 +73,15 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
{children}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
{cancelBtnText ?? 'Cancel'}
|
||||
{cancelBtnText ?? t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{confirmText ?? 'Continue'}
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{confirmText ?? t('dialogs.continue')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user