mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fde24b3db | ||
|
|
e29e8d76b2 | ||
|
|
62532e2740 | ||
|
|
0fc99aa172 | ||
|
|
852ae2b782 | ||
|
|
0fb79c9a8b | ||
|
|
0dad25c993 | ||
|
|
ee7ea3872f | ||
|
|
a440479946 | ||
|
|
ecb81ac344 | ||
|
|
451b5338f1 | ||
|
|
ddd93ff4ab | ||
|
|
0bfe379310 | ||
|
|
e47a81d510 | ||
|
|
50bdf691dc | ||
|
|
11be1e4758 | ||
|
|
694e5ecbec | ||
|
|
579801ef5c | ||
|
|
feedb91225 | ||
|
|
57a6e3c62e | ||
|
|
f7fe1f3072 | ||
|
|
8e25b0da14 | ||
|
|
e1f471b8f4 | ||
|
|
fcd19b1c9f | ||
|
|
df1a6f8c5b | ||
|
|
7d150c2982 | ||
|
|
9b49005522 | ||
|
|
1f4e9f7b06 | ||
|
|
d0e4cac229 | ||
|
|
30a8d856c1 | ||
|
|
7240be8c31 | ||
|
|
0d20a9676a | ||
|
|
48f8092b5a | ||
|
|
a64351d409 | ||
|
|
a6dd1d19ff | ||
|
|
f82b20e2fd | ||
|
|
9841038acb | ||
|
|
97c3db3bd2 | ||
|
|
82fb2a02bc | ||
|
|
c61977ce5c | ||
|
|
106a08fb7e | ||
|
|
3419506c2e | ||
|
|
3b040d0cd6 | ||
|
|
5d3c319a67 | ||
|
|
ad2a43aa35 | ||
|
|
884ae64fc5 | ||
|
|
254de35f0e | ||
|
|
bc3eba5bf7 | ||
|
|
4dc99b4a84 | ||
|
|
8ce8b9692d | ||
|
|
3fb761064d | ||
|
|
54a0a71c44 | ||
|
|
b490923e17 | ||
|
|
4ee44daf0d | ||
|
|
7edd7c2e35 | ||
|
|
0c46432150 | ||
|
|
d334a23ca7 | ||
|
|
1768c1a590 | ||
|
|
147f5b4f55 | ||
|
|
48312dc83e | ||
|
|
55e97510c4 | ||
|
|
0bf2003670 | ||
|
|
e56fe5ebea | ||
|
|
c69ada32ef | ||
|
|
3f4b37be17 | ||
|
|
09375ee11c | ||
|
|
b2e43b0907 | ||
|
|
44fc0e15de | ||
|
|
ef891b20c3 | ||
|
|
a6216c2ce6 | ||
|
|
1c58b516dd | ||
|
|
ae916574de | ||
|
|
14fb3368a3 | ||
|
|
f49929dd67 | ||
|
|
97143d55b8 | ||
|
|
e666f76d87 | ||
|
|
7fb6575f8d | ||
|
|
fb0be8c5d1 | ||
|
|
455e6b1a75 | ||
|
|
75cae51be9 | ||
|
|
62d956c7d6 | ||
|
|
70db81dc03 |
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -6,6 +6,8 @@ on:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
env:
|
||||
BINARY_NAME: bichon
|
||||
BINARY_CTL: bichonctl
|
||||
BINARY_ADMIN: bichon-admin
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -91,6 +93,8 @@ jobs:
|
||||
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}
|
||||
|
||||
- name: Pack artifact (Linux/macOS)
|
||||
if: matrix.os != 'windows-latest'
|
||||
@@ -99,7 +103,9 @@ jobs:
|
||||
mkdir -p release
|
||||
BINARY="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
|
||||
cp README.md LICENSE release/
|
||||
cp "$BINARY" release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_CTL }} release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }} release/
|
||||
tar -czvf "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" -C release .
|
||||
mv "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" release/
|
||||
|
||||
@@ -115,10 +121,14 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
mkdir -p release
|
||||
$BINARY = "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe"
|
||||
Copy-Item -Path $BINARY -Destination release/
|
||||
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" release/
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}.exe" release/
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}.exe" release/
|
||||
|
||||
Copy-Item -Path README.md -Destination release/
|
||||
Copy-Item -Path LICENSE -Destination release/
|
||||
|
||||
Compress-Archive -Path release\* -DestinationPath "release/${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.zip" -Force
|
||||
|
||||
- name: Upload build artifact
|
||||
|
||||
681
Cargo.lock
generated
681
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
50
Cargo.toml
50
Cargo.toml
@@ -1,12 +1,20 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.2.0"
|
||||
version = "0.3.6"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "bichon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bichonctl"
|
||||
path = "src/bin/bichonctl.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bichon-admin"
|
||||
path = "src/bin/bichon_admin.rs"
|
||||
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -19,8 +27,8 @@ opt-level = 3
|
||||
codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.42"
|
||||
clap = { version = "4.5.53", features = ["derive", "env"] }
|
||||
chrono = "0.4.43"
|
||||
clap = { version = "4.5.54", features = ["derive", "env"] }
|
||||
mimalloc = "0.1.48"
|
||||
native_db = "0.8.2"
|
||||
itertools = "0.14.0"
|
||||
@@ -37,9 +45,9 @@ poem-openapi = { version = "5.1.16", features = [
|
||||
] }
|
||||
ring = { version = "0.17.14", features = ["std"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tracing = "0.1.43"
|
||||
serde_json = "1.0.149"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
||||
base64 = "0.22.1"
|
||||
@@ -68,7 +76,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
timeago = "0.5.0"
|
||||
ahash = "0.8.12"
|
||||
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
|
||||
url = { version = "2.5.7", features = ["serde"] }
|
||||
url = { version = "2.5.8", features = ["serde"] }
|
||||
sysinfo = "0.37.2"
|
||||
num_cpus = "1.17.0"
|
||||
cacache = { version = "13.1.0", default-features = false, features = [
|
||||
@@ -81,22 +89,22 @@ async-imap = { version = "0.11.1", default-features = false, features = [
|
||||
"runtime-tokio",
|
||||
"compress",
|
||||
] }
|
||||
webpki-roots = "1.0.4"
|
||||
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
|
||||
rustls-pki-types = "1.13.1"
|
||||
webpki-roots = "1.0.5"
|
||||
rustls = { version = "0.23.36", default-features = false, features = ["ring"] }
|
||||
rustls-pki-types = "1.14.0"
|
||||
tokio-io-timeout = "1.2.1"
|
||||
bb8 = "0.9.1"
|
||||
semver = "1.0.27"
|
||||
governor = "0.10.2"
|
||||
lru = "0.16.2"
|
||||
governor = "0.10.4"
|
||||
lru = "0.16.3"
|
||||
mime_guess = "2.0.5"
|
||||
hex = "0.4.3"
|
||||
time = { version = "0.3.44", features = [
|
||||
time = { version = "0.3.45", features = [
|
||||
"formatting",
|
||||
"parsing",
|
||||
"local-offset",
|
||||
] }
|
||||
rust-embed = "8.9.0"
|
||||
rust-embed = "8.11.0"
|
||||
murmur3 = "0.5.2"
|
||||
autoconfig = "0.4.0"
|
||||
urlencoding = "2.1.3"
|
||||
@@ -105,10 +113,18 @@ dashmap = "6.1.0"
|
||||
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
|
||||
gethostname = "1.1.0"
|
||||
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
|
||||
itoa = "1.0.15"
|
||||
html2text = "0.16.4"
|
||||
itoa = "1.0.17"
|
||||
html2text = "0.16.6"
|
||||
bytes = "1.11.0"
|
||||
dialoguer = "0.12.0"
|
||||
console = "0.16.2"
|
||||
toml = "0.9.8"
|
||||
memmap2 = "0.9.9"
|
||||
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
|
||||
compressed-rtf = "1.0.0"
|
||||
codepage-strings = "1.0.2"
|
||||
mail-send = "0.5.2"
|
||||
[dev-dependencies]
|
||||
#bincode = "1.3.3"
|
||||
#secret-lib = "1.0.0"
|
||||
tempfile = "3.23.0"
|
||||
tempfile = "3.24.0"
|
||||
|
||||
81
README.md
81
README.md
@@ -49,6 +49,16 @@ Built in Rust, it requires no external dependencies and provides fast, efficient
|
||||
| **API Interface** | Typically not provided | Complete REST API |
|
||||
| **Multi-account Management** | Limited | Supports unified search across accounts |
|
||||
|
||||
### 🧠 Intelligent Storage & De-duplication
|
||||
|
||||
Bichon implements a **Single-Instance Storage** philosophy at the account level to maximize storage efficiency and write performance.
|
||||
|
||||
* **Message-ID Centric**: Every email is uniquely identified by its `Message-ID`.
|
||||
* **High-Performance Writes**: Uses an idempotent "Delete-then-Write" strategy to ensure the fastest possible indexing speed.
|
||||
* **Automatic State Updates**: Moving an email between folders (e.g., from Inbox to Trash) will update the existing record rather than creating a duplicate.
|
||||
* **Lean Imports**: Duplicate emails encountered during `nosync` bulk imports are automatically merged.
|
||||
|
||||
👉 [**Deep Dive: How Bichon handles de-duplication**](https://github.com/rustmailer/bichon/wiki/De%E2%80%90duplication)
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
@@ -65,7 +75,7 @@ Built in Rust, it requires no external dependencies and provides fast, efficient
|
||||
* **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
|
||||
|
||||
* **Email Import (EML, MBOX & PST)** — Import existing mail archives via the bichonctl CLI
|
||||
|
||||
## 🐾 Why Create Bichon?
|
||||
|
||||
@@ -92,7 +102,7 @@ It’s not perfect, but I hope it brings you value.
|
||||
<img width="1909" height="904" alt="image" src="https://github.com/user-attachments/assets/ab4bf6ae-faa6-4b49-ae39-705eb9d4487f" />
|
||||
<img width="1910" height="910" alt="image" src="https://github.com/user-attachments/assets/bcf9cca2-d690-4e7b-b2c9-c52a31c7b999" />
|
||||
<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="1910" height="1055" alt="image" src="https://github.com/user-attachments/assets/9bde665e-7717-447f-ad29-f743a32a4dc0" />
|
||||
<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" />
|
||||
|
||||
@@ -112,14 +122,34 @@ docker pull rustmailer/bichon:latest
|
||||
# Create data directory
|
||||
mkdir -p ./bichon-data
|
||||
|
||||
# Optional: Set PUID and PGID to match your host user for proper file permissions
|
||||
# Find your user ID with: id $USER
|
||||
# This prevents permission issues when using NFS mounts or shared volumes
|
||||
|
||||
# Run container
|
||||
docker run -d \
|
||||
--name bichon \
|
||||
-p 15630:15630 \
|
||||
-v $(pwd)/bichon-data:/data \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e BICHON_LOG_LEVEL=info \
|
||||
-e BICHON_ROOT_DIR=/data \
|
||||
rustmailer/bichon:latest
|
||||
|
||||
# Optional: For custom storage configuration with separate volumes
|
||||
docker run -d \
|
||||
--name bichon \
|
||||
-p 15630:15630 \
|
||||
-v $(pwd)/bichon-data:/data \
|
||||
-v $(pwd)/envelope:/envelope \
|
||||
-v $(pwd)/eml:/eml \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e BICHON_ROOT_DIR=/data \
|
||||
-e BICHON_INDEX_DIR=/envelope \
|
||||
-e BICHON_DATA_DIR=/eml \
|
||||
rustmailer/bichon:latest
|
||||
```
|
||||
|
||||
## CORS Configuration (Important for Browser Access)
|
||||
@@ -304,6 +334,20 @@ After logging in, the admin user can manage their profile directly in the WebUI:
|
||||
⚠️ **Security Notice:**
|
||||
For security reasons, you should **change the default admin password immediately after the first login**.
|
||||
|
||||
## 📦 Import Existing Mail Archives
|
||||
|
||||
If you already have existing emails stored as **EML** or **MBOX** files, you can import them into Bichon using the `bichonctl` CLI.
|
||||
|
||||
This allows you to:
|
||||
|
||||
- Index historical emails
|
||||
- Perform full-text search immediately
|
||||
- Manage imported data just like synced IMAP emails
|
||||
|
||||
📖 **Full documentation:**
|
||||
👉 https://github.com/rustmailer/bichon/wiki/Using-Bichonctl-For-Email-Import
|
||||
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
> Under construction. Documentation will be available soon.
|
||||
@@ -335,13 +379,13 @@ This data is provided solely as a **reference** for real-world usage. We encoura
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✓ Multi-user support with account/password login
|
||||
- System-level roles (admin / user)
|
||||
- Per-mail-account permissions
|
||||
* [x] Multi-user support with account/password login
|
||||
* [x] System-level roles (admin / user)
|
||||
* [x] Per-mail-account permissions
|
||||
|
||||
* [ ] `bichon-cli` command-line tool
|
||||
* [x] `bichonctl` command-line tool
|
||||
|
||||
* Import emails from `eml`, `mbox`, `msg`, `pst`
|
||||
* [x] Import emails from `eml`, `mbox`, `pst` (Single file)
|
||||
|
||||
* [ ] Manual sync controls
|
||||
|
||||
@@ -364,7 +408,18 @@ This data is provided solely as a **reference** for real-world usage. We encoura
|
||||
* Sync emails to a specified target account
|
||||
* Support mailbox migration
|
||||
|
||||
---
|
||||
* [ ] SMTP server / gateway support
|
||||
|
||||
* Provide a lightweight SMTP receiving service
|
||||
* Allow direct forwarding of incoming mail to Bichon at the gateway level
|
||||
* Achieve more reliable, real-time, complete email archiving & backup
|
||||
* Optional: support alias / catch-all / domain-level routing
|
||||
|
||||
* [ ] MCP Server
|
||||
|
||||
* Provide an LLM interface for advanced email search and intelligent processing
|
||||
* Enable natural language queries to search and understand email content
|
||||
* Make Bichon capable of smarter email interaction and analysis
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
@@ -455,9 +510,13 @@ This project is licensed under [AGPLv3](LICENSE).
|
||||
|
||||
## 💖 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.
|
||||
Bichon is an open-source email platform focused on privacy, local ownership, and long-term stability.
|
||||
|
||||
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!
|
||||
The project is freely available and fully functional for everyone.
|
||||
|
||||
Some members of the community choose to support the project financially. This support helps sustain ongoing development and long-term maintenance, while keeping the project independent and user-driven.
|
||||
|
||||
Support is always optional. You can also contribute by sharing feedback, reporting issues, or recommending Bichon to others.
|
||||
|
||||
[](https://buymeacoffee.com/rustmailer)
|
||||
|
||||
|
||||
2
config.toml
Normal file
2
config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
base_url = "http://localhost:15630"
|
||||
api_token = "lZHmfpH1CRr9XsRiOGd1RnOr"
|
||||
@@ -14,8 +14,20 @@ WORKDIR /opt/bichon
|
||||
# Copy compiled binary (ensure it's statically linked or compatible with bullseye)
|
||||
COPY ${TARGETARCH}/bichon /opt/bichon/bichon
|
||||
COPY ${TARGETARCH}/LICENSE /opt/bichon/
|
||||
# Set proper permissions
|
||||
|
||||
|
||||
# cli tools
|
||||
COPY ${TARGETARCH}/bichonctl /usr/local/bin/bichonctl
|
||||
COPY ${TARGETARCH}/bichon-admin /usr/local/bin/bichon-admin
|
||||
|
||||
# Set permissions
|
||||
RUN chmod +x /opt/bichon/bichon
|
||||
RUN chmod +x /usr/local/bin/bichonctl
|
||||
RUN chmod +x /usr/local/bin/bichon-admin
|
||||
|
||||
# Copy and setup entrypoint script for PUID/PGID support
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Install ca-certificates to ensure HTTPS certificate verification works correctly
|
||||
RUN apt update && apt install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
@@ -31,5 +43,6 @@ WORKDIR /data
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -fs http://localhost:15630/api/status || exit 1
|
||||
|
||||
# Entrypoint remains the binary
|
||||
# Entrypoint with PUID/PGID support
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["/opt/bichon/bichon"]
|
||||
|
||||
54
docker/entrypoint.sh
Normal file
54
docker/entrypoint.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Entrypoint script for Bichon Docker container
|
||||
# Handles PUID/PGID environment variables for proper user permissions
|
||||
|
||||
set -e
|
||||
|
||||
# If not running as root, do nothing
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
echo "Running as non-root user ($(id -u)), skipping PUID/PGID handling"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
|
||||
# Function to create user and switch to it
|
||||
switch_user() {
|
||||
local puid="$1"
|
||||
local pgid="$2"
|
||||
local USER_NAME
|
||||
local GROUP_NAME
|
||||
|
||||
# group
|
||||
if getent group "$pgid" >/dev/null 2>&1; then
|
||||
GROUP_NAME=$(getent group "$pgid" | cut -d: -f1)
|
||||
else
|
||||
groupadd -g "$pgid" bichon
|
||||
GROUP_NAME=bichon
|
||||
fi
|
||||
|
||||
# user
|
||||
if getent passwd "$puid" >/dev/null 2>&1; then
|
||||
USER_NAME=$(getent passwd "$puid" | cut -d: -f1)
|
||||
else
|
||||
useradd -u "$puid" -g "$GROUP_NAME" -s /bin/bash -d /data bichon
|
||||
USER_NAME=bichon
|
||||
fi
|
||||
|
||||
chown -R "$puid:$pgid" /data
|
||||
chown -R "$puid:$pgid" /opt/bichon
|
||||
[ -d /envelope ] && chown -R "$puid:$pgid" /envelope
|
||||
[ -d /eml ] && chown -R "$puid:$pgid" /eml
|
||||
|
||||
exec runuser -u "$USER_NAME" -- "$@"
|
||||
}
|
||||
|
||||
|
||||
# Check if PUID and PGID are set
|
||||
if [ -n "$PUID" ] && [ -n "$PGID" ]; then
|
||||
echo "Switching to user with PUID=$PUID, PGID=$PGID"
|
||||
switch_user "$PUID" "$PGID" "$@"
|
||||
else
|
||||
echo "No PUID/PGID specified, running as root"
|
||||
exec "$@"
|
||||
fi
|
||||
2
env/rustmailer.env → env/bichon.env
vendored
2
env/rustmailer.env → env/bichon.env
vendored
@@ -28,7 +28,7 @@ BICHON_ROOT_DIR=/data/bichon-data
|
||||
# Enable API access token validation
|
||||
BICHON_ENABLE_ACCESS_TOKEN=false
|
||||
|
||||
# IP address to bind the HTTP and gRPC servers to (default: 0.0.0.0)
|
||||
# IP address to bind the HTTP servers to (default: 0.0.0.0)
|
||||
BICHON_BIND_IP=
|
||||
|
||||
# Comma-separated list of allowed CORS origins (e.g. https://app.example.com)
|
||||
301
src/bin/bichon_admin.rs
Normal file
301
src/bin/bichon_admin.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use bichon::modules::{
|
||||
cli::admin::meta::{find_admin, init_meta_database, update_admin_password},
|
||||
error::BichonError,
|
||||
utils::encrypt::internal_decrypt_string,
|
||||
};
|
||||
use console::{style, Emoji};
|
||||
use dialoguer::Confirm;
|
||||
use dialoguer::{theme::ColorfulTheme, Input, Password, Select};
|
||||
use native_db::Database;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let theme = ColorfulTheme::default();
|
||||
println!(
|
||||
"\n{}\n",
|
||||
style("BICHON ADMINISTRATIVE TOOL").bold().bright().cyan()
|
||||
);
|
||||
|
||||
let main_options = vec!["Reset Admin Password", "Exit"];
|
||||
let selection = Select::with_theme(&theme)
|
||||
.with_prompt("Select an operation")
|
||||
.default(0)
|
||||
.items(&main_options)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
if selection == 1 {
|
||||
println!("{}", style("Exiting...").dim());
|
||||
return;
|
||||
}
|
||||
|
||||
let root_dir_str: String = Input::with_theme(&theme)
|
||||
.with_prompt("Enter the absolute path for 'bichon_root_dir'")
|
||||
.validate_with(|input: &String| -> Result<(), &str> {
|
||||
let path = Path::new(input);
|
||||
if !path.is_absolute() {
|
||||
return Err("Path must be absolute.");
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err("Directory does not exist.");
|
||||
}
|
||||
let has_metadata = path.join("meta.db").exists();
|
||||
if !has_metadata {
|
||||
return Err("Invalid directory: 'meta.db' not found.");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let root_path = PathBuf::from(&root_dir_str);
|
||||
|
||||
let database: Rc<Database<'static>> = match init_meta_database(&root_path.join("meta.db")) {
|
||||
Ok(database) => database,
|
||||
Err(e) => match e {
|
||||
BichonError::Generic {
|
||||
message,
|
||||
location,
|
||||
code,
|
||||
} => {
|
||||
if message.contains("RedbDatabaseError(DatabaseAlreadyOpen") {
|
||||
println!("\n{}", style("ERROR: Database is locked.").red().bold());
|
||||
println!(
|
||||
"{}",
|
||||
style("The Bichon service is likely still running.").yellow()
|
||||
);
|
||||
println!(
|
||||
"Since the database cannot be shared between multiple instances, \n\
|
||||
you must {} the Bichon service before proceeding.",
|
||||
style("STOP").underlined().bold()
|
||||
);
|
||||
std::process::exit(1);
|
||||
} else {
|
||||
eprintln!(
|
||||
"\n{} (Code: {:#?})\nLocation: {}\nMessage: {}",
|
||||
style("A database error occurred:").red().bold(),
|
||||
code,
|
||||
location,
|
||||
message
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let admin = find_admin(&database);
|
||||
|
||||
match admin {
|
||||
Ok(Some(user)) => {
|
||||
println!("\n{}", style("Admin user found:").green().bold());
|
||||
println!("----------------------------------------");
|
||||
println!("{:<12} : {}", "Username", style(&user.username).cyan());
|
||||
println!("{:<12} : {}", "Email", style(&user.email).cyan());
|
||||
}
|
||||
Ok(None) => {
|
||||
println!(
|
||||
"\n{}",
|
||||
style("ERROR: No admin user found in the database.")
|
||||
.red()
|
||||
.bold()
|
||||
);
|
||||
println!("Please ensure the system has been initialized correctly.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"\n{} Failed to query admin user.",
|
||||
style("ERROR:").red().bold()
|
||||
);
|
||||
eprintln!("Details: {:?}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let encryption_key = loop {
|
||||
let auth_methods = vec![
|
||||
"Enter encryption password manually",
|
||||
"Read from password file",
|
||||
];
|
||||
let method = Select::with_theme(&theme)
|
||||
.with_prompt("How would you like to provide the database encryption key?")
|
||||
.items(&auth_methods)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let raw_key = if method == 0 {
|
||||
Password::with_theme(&theme)
|
||||
.with_prompt("Enter Encryption Password")
|
||||
.interact()
|
||||
.unwrap()
|
||||
} else {
|
||||
let file_path: String = Input::with_theme(&theme)
|
||||
.with_prompt("Enter path to encryption password file")
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
match fs::read_to_string(&file_path) {
|
||||
Ok(content) => content.trim().to_string(),
|
||||
Err(e) => {
|
||||
println!("{}: {}", style("Failed to read file").red(), e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if raw_key.is_empty() {
|
||||
println!("{}", style("Key cannot be empty.").red());
|
||||
continue;
|
||||
}
|
||||
|
||||
let prompt_message = format!(
|
||||
"Encryption key loaded: [ {} ]\n\n \
|
||||
{}: This key must match the database encryption key used by the server.\n \
|
||||
It corresponds to these settings in your service:\n \
|
||||
- Arguments: {} or {}\n \
|
||||
- Envs: {} or {}\n\n \
|
||||
Do you want to continue?",
|
||||
style(&raw_key).cyan().bold(),
|
||||
style("IMPORTANT").yellow().bold(),
|
||||
style("--bichon_encrypt_password").italic(),
|
||||
style("--bichon_encrypt_password_file").italic(),
|
||||
style("BICHON_ENCRYPT_PASSWORD").green(),
|
||||
style("BICHON_ENCRYPT_PASSWORD_FILE").green()
|
||||
);
|
||||
|
||||
if Confirm::with_theme(&theme)
|
||||
.with_prompt(prompt_message)
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
break raw_key;
|
||||
}
|
||||
};
|
||||
|
||||
let admin = find_admin(&database);
|
||||
|
||||
match admin {
|
||||
Ok(Some(user)) => {
|
||||
println!("----------------------------------------");
|
||||
println!("{:<12} : {}", "Username", style(&user.username).cyan());
|
||||
println!("{:<12} : {}", "Email", style(&user.email).cyan());
|
||||
|
||||
let pwd_display = match &user.password {
|
||||
Some(p) => {
|
||||
let password = match internal_decrypt_string(&encryption_key, p) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
println!("\n{}", style("ERROR: Decryption Failed").red().bold());
|
||||
println!(
|
||||
"{}",
|
||||
style("The provided encryption key is incorrect or invalid for this database.").yellow()
|
||||
);
|
||||
println!(
|
||||
"{} Please verify your {} or the {} you provided.",
|
||||
style("➔").cyan(),
|
||||
style("encryption password").bold(),
|
||||
style("key file").bold()
|
||||
);
|
||||
eprintln!("\nTechnical details: {:?}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
style(password).yellow().to_string()
|
||||
}
|
||||
None => style("None (No password set)").dim().italic().to_string(),
|
||||
};
|
||||
|
||||
println!("{:<12} : {}", "Password", pwd_display);
|
||||
println!("----------------------------------------");
|
||||
|
||||
if !dialoguer::Confirm::with_theme(&theme)
|
||||
.with_prompt(format!(
|
||||
"Do you want to reset the password for '{}'?",
|
||||
user.username
|
||||
))
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
println!("Operation cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
println!(
|
||||
"\n{}",
|
||||
style("ERROR: No admin user found in the database.")
|
||||
.red()
|
||||
.bold()
|
||||
);
|
||||
println!("Please ensure the system has been initialized correctly.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"\n{} Failed to query admin user.",
|
||||
style("ERROR:").red().bold()
|
||||
);
|
||||
eprintln!("Details: {:?}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n{}",
|
||||
style("TARGET: Reset password for user 'admin'")
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
|
||||
let new_login_password = Password::with_theme(&theme)
|
||||
.with_prompt("Enter new Admin Login Password")
|
||||
.with_confirmation("Repeat password to confirm", "Passwords do not match!")
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
if !Confirm::with_theme(&theme)
|
||||
.with_prompt("Proceed with database update?")
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n{} {}", style("⌛").yellow(), "Updating database...");
|
||||
|
||||
match update_admin_password(&database, new_login_password, &encryption_key) {
|
||||
Ok(_) => {
|
||||
println!(
|
||||
"\n{} {}",
|
||||
Emoji("✨", "*"),
|
||||
style("Success! Admin password has been updated.")
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
style("You can now log in with the new password.").dim()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"\n{}",
|
||||
style("ERROR: Failed to update database").red().bold()
|
||||
);
|
||||
eprintln!(
|
||||
"{} Could not save the new password to the database.",
|
||||
style("➔").cyan()
|
||||
);
|
||||
eprintln!("\nDetails: {:?}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/bin/bichonctl.rs
Normal file
95
src/bin/bichonctl.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use bichon::modules::cli::{
|
||||
BichonCli, BichonCtlConfig, auth::verify_user_and_get_account, eml::handle_eml_directory_import, mbox::handle_mbox_single_file_import, pst::handle_pst_import, thunderbird::handle_thunderbird_import
|
||||
};
|
||||
use clap::Parser;
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
|
||||
use std::fs;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = BichonCli::parse();
|
||||
let theme = ColorfulTheme::default();
|
||||
let config_path = &cli.config;
|
||||
let mut current_config: Option<BichonCtlConfig> = None;
|
||||
|
||||
if config_path.exists() {
|
||||
if let Ok(content) = fs::read_to_string(config_path) {
|
||||
if let Ok(config) = toml::from_str::<BichonCtlConfig>(&content) {
|
||||
println!("{}", style("✔ Existing configuration found:").green());
|
||||
println!(" Base URL: {}", style(&config.base_url).yellow());
|
||||
println!(" API Token: {}", style(&config.api_token).yellow());
|
||||
|
||||
// Confirm with user
|
||||
if Confirm::with_theme(&theme)
|
||||
.with_prompt("Do you want to use this configuration?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
current_config = Some(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let final_config = match current_config {
|
||||
Some(conf) => conf,
|
||||
None => {
|
||||
println!("\n{}", style("Please enter Bichon service details:").bold());
|
||||
|
||||
let url: String = Input::with_theme(&theme)
|
||||
.with_prompt("Bichon Base URL")
|
||||
.default("http://localhost:15630".into())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let token: String = Input::with_theme(&theme)
|
||||
.with_prompt("API Token")
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let conf = BichonCtlConfig {
|
||||
base_url: url,
|
||||
api_token: token,
|
||||
};
|
||||
|
||||
// 3. Offer to save the new configuration
|
||||
if Confirm::with_theme(&theme)
|
||||
.with_prompt("Save this configuration for future use?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
let toml_str = toml::to_string(&conf).unwrap();
|
||||
fs::write(config_path, toml_str).expect("Failed to save config file");
|
||||
println!("{}", style("Configuration saved successfully!").green());
|
||||
}
|
||||
conf
|
||||
}
|
||||
};
|
||||
|
||||
let target_account_id = verify_user_and_get_account(&final_config, &theme).await;
|
||||
|
||||
let import_modes = &[
|
||||
"EML: Scan directory recursively (Maintains folder structure)",
|
||||
"MBOX: Single archive file (Stream from one file)",
|
||||
"Thunderbird: Import from local profile directory",
|
||||
"PST: Outlook Personal Storage (Single .pst file)",
|
||||
];
|
||||
|
||||
let mode_idx = Select::with_theme(&theme)
|
||||
.with_prompt("Select import method")
|
||||
.items(import_modes)
|
||||
.default(0)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
match mode_idx {
|
||||
0 => handle_eml_directory_import(&final_config, target_account_id, &theme).await,
|
||||
1 => handle_mbox_single_file_import(&final_config, target_account_id, &theme).await,
|
||||
2 => handle_thunderbird_import(&final_config, target_account_id, &theme).await,
|
||||
3 => handle_pst_import(&final_config, target_account_id, &theme).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
1
src/lib.rs
Normal file
1
src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod modules;
|
||||
23
src/main.rs
23
src/main.rs
@@ -16,23 +16,24 @@
|
||||
// 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,
|
||||
context::{executors::EmailClientExecutors, Initialize},
|
||||
error::BichonResult,
|
||||
logger,
|
||||
rest::start_http_server,
|
||||
tasks::PeriodicTasks,
|
||||
use bichon::{
|
||||
bichon_version,
|
||||
modules::{
|
||||
common::rustls::RustMailerTls,
|
||||
context::{executors::EmailClientExecutors, Initialize},
|
||||
error::BichonResult,
|
||||
logger,
|
||||
rest::start_http_server,
|
||||
tasks::PeriodicTasks,
|
||||
},
|
||||
};
|
||||
use mimalloc::MiMalloc;
|
||||
use tracing::info;
|
||||
|
||||
use crate::modules::{
|
||||
use bichon::modules::{
|
||||
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
|
||||
};
|
||||
|
||||
mod modules;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::{
|
||||
users::{
|
||||
permissions::Permission,
|
||||
role::{RoleType, UserRole},
|
||||
BichonUser,
|
||||
UserModel,
|
||||
},
|
||||
},
|
||||
raise_error, utc_now,
|
||||
@@ -68,7 +68,7 @@ impl BatchAccountRoleRequest {
|
||||
}
|
||||
|
||||
for id in &self.user_ids {
|
||||
let exists = BichonUser::find(*id).await?; // Assuming an exists helper
|
||||
let exists = UserModel::find(*id).await?; // Assuming an exists helper
|
||||
if exists.is_none() {
|
||||
return Err(raise_error!(
|
||||
format!("User ID {} not found", id),
|
||||
@@ -90,7 +90,7 @@ impl BatchAccountRoleRequest {
|
||||
// Fetch the current user record from the database
|
||||
let user = rw
|
||||
.get()
|
||||
.primary::<BichonUser>(uid)
|
||||
.primary::<UserModel>(uid)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
|
||||
@@ -36,7 +36,7 @@ use crate::{
|
||||
database::{list_all_impl, with_transaction},
|
||||
error::BichonResult,
|
||||
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, BichonUser, DEFAULT_ADMIN_USER_ID},
|
||||
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID},
|
||||
},
|
||||
utc_now,
|
||||
};
|
||||
@@ -195,13 +195,6 @@ impl AccountV3 {
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
// if !account.enabled {
|
||||
// return Err(raise_error!(
|
||||
// format!("Account id='{account_id}' is disabled"),
|
||||
// ErrorCode::AccountDisabled
|
||||
// ));
|
||||
// }
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
@@ -221,11 +214,6 @@ impl AccountV3 {
|
||||
.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,
|
||||
@@ -238,7 +226,7 @@ impl AccountV3 {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let user = rw
|
||||
.get()
|
||||
.primary::<BichonUser>(user_id)
|
||||
.primary::<UserModel>(user_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -312,7 +300,7 @@ impl AccountV3 {
|
||||
MAIL_CONTEXT.clean_account(account.id).await?;
|
||||
}
|
||||
OAuth2AccessToken::try_delete(account.id).await?;
|
||||
BichonUser::cleanup_account(account.id).await?;
|
||||
UserModel::cleanup_account(account.id).await?;
|
||||
MailBox::clean(account.id).await?;
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.delete_account_envelopes(account.id)
|
||||
@@ -375,11 +363,13 @@ impl AccountV3 {
|
||||
list_all_impl(DB_MANAGER.meta_db()).await
|
||||
}
|
||||
|
||||
pub async fn minimal_list() -> BichonResult<Vec<MinimalAccount>> {
|
||||
pub async fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
|
||||
let result = list_all_impl(DB_MANAGER.meta_db())
|
||||
.await?
|
||||
.into_iter()
|
||||
//.filter(|a: &AccountModel| a.enabled)
|
||||
.filter(|account: &AccountModel| {
|
||||
!only_nosync || matches!(account.account_type, AccountType::NoSync)
|
||||
})
|
||||
.map(|account: AccountModel| MinimalAccount {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
@@ -420,10 +410,23 @@ impl AccountV3 {
|
||||
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 {
|
||||
new.folder_limit = Some(folder_limit);
|
||||
}
|
||||
|
||||
if let Some(clear_folder_limit) = request.clear_folder_limit {
|
||||
if clear_folder_limit {
|
||||
new.folder_limit = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = &request.name {
|
||||
if name.trim().is_empty() {
|
||||
new.name = None;
|
||||
|
||||
@@ -37,7 +37,7 @@ pub struct AccountCreateRequest {
|
||||
pub account_type: AccountType,
|
||||
#[oai(validator(minimum(value = "100")))]
|
||||
pub folder_limit: Option<u32>,
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
#[oai(validator(minimum(value = "10")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
||||
pub sync_batch_size: Option<u32>,
|
||||
@@ -121,11 +121,13 @@ pub struct AccountUpdateRequest {
|
||||
/// - 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).
|
||||
#[oai(validator(minimum(value = "100")))]
|
||||
pub folder_limit: Option<u32>,
|
||||
pub clear_folder_limit: Option<bool>,
|
||||
/// Configuration for selective folder (mailbox/label) synchronization
|
||||
///
|
||||
/// - For IMAP/SMTP accounts:
|
||||
@@ -141,7 +143,7 @@ pub struct AccountUpdateRequest {
|
||||
/// Modified folders will be automatically synced on the next update.
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
/// Incremental sync interval (seconds)
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
#[oai(validator(minimum(value = "10")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
||||
pub sync_batch_size: Option<u32>,
|
||||
@@ -165,6 +167,22 @@ impl AccountUpdateRequest {
|
||||
));
|
||||
}
|
||||
|
||||
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
|
||||
return Err(raise_error!(
|
||||
"clear_folder_limit cannot be combined with folder_limit".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()?;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::modules::{
|
||||
migration::{AccountModel, AccountType},
|
||||
since::{DateSince, RelativeDate},
|
||||
},
|
||||
users::BichonUser,
|
||||
users::UserModel,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
@@ -57,7 +57,7 @@ pub struct AccountResp {
|
||||
}
|
||||
|
||||
impl AccountResp {
|
||||
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, BichonUser>) -> 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,
|
||||
|
||||
41
src/modules/cache/imap/mailbox.rs
vendored
41
src/modules/cache/imap/mailbox.rs
vendored
@@ -16,13 +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::{
|
||||
decode_mailbox_name, encode_mailbox_name,
|
||||
modules::{
|
||||
database::{
|
||||
batch_delete_impl, batch_insert_impl, batch_upsert_impl, filter_by_secondary_key_impl,
|
||||
manager::DB_MANAGER,
|
||||
async_find_impl, batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl,
|
||||
filter_by_secondary_key_impl, manager::DB_MANAGER,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
},
|
||||
@@ -90,25 +89,25 @@ impl MailBox {
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
// pub async fn get(id: u64) -> RustMailerResult<MailBox> {
|
||||
// let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
|
||||
// Ok(result.ok_or_else(|| {
|
||||
// raise_error!(
|
||||
// format!("mailbox {} not found", id),
|
||||
// ErrorCode::InternalError
|
||||
// )
|
||||
// })?)
|
||||
// }
|
||||
pub async fn get(id: u64) -> BichonResult<MailBox> {
|
||||
let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
|
||||
Ok(result.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("mailbox {} not found", id),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?)
|
||||
}
|
||||
|
||||
// pub async fn delete(id: u64) -> BichonResult<()> {
|
||||
// delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
||||
// rw.get()
|
||||
// .primary::<MailBox>(id)
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
// .ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
|
||||
// })
|
||||
// .await
|
||||
// }
|
||||
pub async fn delete(id: u64) -> BichonResult<()> {
|
||||
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
||||
rw.get()
|
||||
.primary::<MailBox>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
|
||||
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id)
|
||||
|
||||
102
src/modules/cache/imap/sync/flow.rs
vendored
102
src/modules/cache/imap/sync/flow.rs
vendored
@@ -23,7 +23,10 @@ use crate::{
|
||||
imap::{
|
||||
find_intersecting_mailboxes, find_missing_mailboxes,
|
||||
mailbox::MailBox,
|
||||
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
|
||||
sync::rebuild::{
|
||||
rebuild_mailbox_cache, rebuild_mailbox_cache_by_date,
|
||||
DEFAULT_MAX_CONCURRENT_PER_ACCOUNT,
|
||||
},
|
||||
},
|
||||
SEMAPHORE,
|
||||
},
|
||||
@@ -33,7 +36,8 @@ use crate::{
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use std::time::Instant;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
pub const DEFAULT_BATCH_SIZE: u32 = 50;
|
||||
@@ -335,46 +339,64 @@ pub async fn reconcile_mailboxes(
|
||||
if mailbox.exists > 0 {
|
||||
let account = account.clone();
|
||||
let mailbox = mailbox.clone();
|
||||
match SEMAPHORE.clone().acquire_owned().await {
|
||||
Ok(permit) => {
|
||||
let handle: tokio::task::JoinHandle<Result<(), BichonError>> =
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
match &account.date_since {
|
||||
Some(date_since) => {
|
||||
rebuild_mailbox_cache_by_date(
|
||||
&account,
|
||||
mailbox.id,
|
||||
&date_since.since_date()?,
|
||||
&mailbox,
|
||||
FetchDirection::Since,
|
||||
)
|
||||
.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);
|
||||
}
|
||||
|
||||
let local_semaphore = Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_PER_ACCOUNT));
|
||||
|
||||
let global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!("Failed to acquire semaphore permit, error: {:#?}", err);
|
||||
error!(
|
||||
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
|
||||
account.id, &mailbox.name, err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let local_permit = match local_semaphore.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to acquire local semaphore permit for account {} mailbox '{}': {:#?}",
|
||||
account.id, &mailbox.name, err
|
||||
);
|
||||
drop(global_permit);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let handle: tokio::task::JoinHandle<Result<(), BichonError>> =
|
||||
tokio::spawn(async move {
|
||||
let _global_permit = global_permit;
|
||||
let _local_permit = local_permit;
|
||||
|
||||
match &account.date_since {
|
||||
Some(date_since) => {
|
||||
rebuild_mailbox_cache_by_date(
|
||||
&account,
|
||||
mailbox.id,
|
||||
&date_since.since_date()?,
|
||||
&mailbox,
|
||||
FetchDirection::Since,
|
||||
)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
93
src/modules/cache/imap/sync/rebuild.rs
vendored
93
src/modules/cache/imap/sync/rebuild.rs
vendored
@@ -31,9 +31,12 @@ use crate::{
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use std::time::Instant;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub const DEFAULT_MAX_CONCURRENT_PER_ACCOUNT: usize = 5;
|
||||
|
||||
pub async fn rebuild_cache(
|
||||
account: &AccountModel,
|
||||
remote_mailboxes: &[MailBox],
|
||||
@@ -42,6 +45,8 @@ pub async fn rebuild_cache(
|
||||
let mut total_inserted = 0;
|
||||
MailBox::batch_insert(remote_mailboxes).await?;
|
||||
|
||||
let local_semaphore = Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_PER_ACCOUNT));
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for mailbox in remote_mailboxes {
|
||||
if mailbox.exists == 0 {
|
||||
@@ -53,20 +58,40 @@ pub async fn rebuild_cache(
|
||||
}
|
||||
let account = account.clone();
|
||||
let mailbox = mailbox.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_full_mailbox(&account, &mailbox, mailbox.exists).await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
let global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!("Failed to acquire semaphore permit, error: {:#?}", err);
|
||||
error!(
|
||||
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
|
||||
account.id, &mailbox.name, err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let local_permit = match local_semaphore.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to acquire local semaphore permit for account {} mailbox '{}': {:#?}",
|
||||
account.id, &mailbox.name, err
|
||||
);
|
||||
drop(global_permit);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let handle: tokio::task::JoinHandle<Result<usize, BichonError>> =
|
||||
tokio::spawn(async move {
|
||||
let _global_permit = global_permit;
|
||||
let _local_permit = local_permit;
|
||||
|
||||
fetch_and_save_full_mailbox(&account, &mailbox, mailbox.exists).await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
for task in handles {
|
||||
match task.await {
|
||||
Ok(Ok(count)) => {
|
||||
@@ -96,6 +121,9 @@ pub async fn rebuild_cache_by_date(
|
||||
MailBox::batch_insert(remote_mailboxes).await?;
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
let local_semaphore = Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_PER_ACCOUNT));
|
||||
|
||||
for mailbox in remote_mailboxes {
|
||||
if mailbox.exists == 0 {
|
||||
info!(
|
||||
@@ -108,19 +136,38 @@ pub async fn rebuild_cache_by_date(
|
||||
let mailbox = mailbox.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_by_date(&account, date.as_str(), &mailbox, direction).await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
let global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!("Failed to acquire semaphore permit, error: {:#?}", err);
|
||||
error!(
|
||||
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
|
||||
account.id, &mailbox.name, err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let local_permit = match local_semaphore.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to acquire local semaphore permit for account {} mailbox '{}': {:#?}",
|
||||
account.id, &mailbox.name, err
|
||||
);
|
||||
drop(global_permit);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let handle: tokio::task::JoinHandle<Result<usize, BichonError>> =
|
||||
tokio::spawn(async move {
|
||||
let _global_permit = global_permit;
|
||||
let _local_permit = local_permit;
|
||||
|
||||
fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction).await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
for task in handles {
|
||||
match task.await {
|
||||
|
||||
111
src/modules/cli/admin/meta.rs
Normal file
111
src/modules/cli/admin/meta.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use std::{path::Path, rc::Rc};
|
||||
|
||||
use native_db::{Builder, Database};
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
database::META_MODELS,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
token::{AccessTokenModel, AccessTokenModelKey, TokenType},
|
||||
users::{UserModel, DEFAULT_ADMIN_USER_ID},
|
||||
utils::encrypt::internal_encrypt_string,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
|
||||
pub fn init_meta_database(path: impl AsRef<Path>) -> BichonResult<Rc<Database<'static>>> {
|
||||
let database = Builder::new()
|
||||
.set_cache_size(134217728)
|
||||
.create(&META_MODELS, path)
|
||||
.map_err(|e| {
|
||||
raise_error!(
|
||||
format!("Failed to open database: {:?}", e),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Rc::new(database))
|
||||
}
|
||||
|
||||
pub fn find_admin(database: &Rc<Database<'static>>) -> BichonResult<Option<UserModel>> {
|
||||
let r_transaction = database
|
||||
.r_transaction()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let entity: Option<UserModel> = r_transaction
|
||||
.get()
|
||||
.primary(DEFAULT_ADMIN_USER_ID)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
return Ok(entity);
|
||||
}
|
||||
|
||||
pub fn update_admin_password(
|
||||
database: &Rc<Database<'static>>,
|
||||
password: String,
|
||||
encrypt_key: &str,
|
||||
) -> BichonResult<()> {
|
||||
let rw_transaction = database
|
||||
.rw_transaction()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let entity: UserModel = rw_transaction
|
||||
.get()
|
||||
.primary(DEFAULT_ADMIN_USER_ID)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| raise_error!("admin is not found".into(), ErrorCode::InternalError))?;
|
||||
|
||||
let mut updated = entity.clone();
|
||||
updated.password = Some(
|
||||
internal_encrypt_string(encrypt_key, &password)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?,
|
||||
);
|
||||
|
||||
rw_transaction
|
||||
.update(entity, updated)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw_transaction
|
||||
.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
pub fn reset_webui_token(database: &Rc<Database<'static>>) -> BichonResult<()> {
|
||||
let rw_transaction = database
|
||||
.rw_transaction()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
let tokens: Vec<AccessTokenModel> = rw_transaction
|
||||
.scan()
|
||||
.secondary(AccessTokenModelKey::user_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.start_with(DEFAULT_ADMIN_USER_ID)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.try_collect()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
let webui_token = tokens
|
||||
.into_iter()
|
||||
.find(|t| t.token_type == TokenType::WebUI);
|
||||
|
||||
let new_token = AccessTokenModel::new_webui_token(DEFAULT_ADMIN_USER_ID);
|
||||
match webui_token {
|
||||
Some(current) => {
|
||||
rw_transaction
|
||||
.remove(current)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
rw_transaction
|
||||
.insert(new_token)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
}
|
||||
None => {
|
||||
rw_transaction
|
||||
.insert(new_token)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
}
|
||||
}
|
||||
|
||||
rw_transaction
|
||||
.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(())
|
||||
}
|
||||
1
src/modules/cli/admin/mod.rs
Normal file
1
src/modules/cli/admin/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod meta;
|
||||
169
src/modules/cli/auth.rs
Normal file
169
src/modules/cli/auth.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use std::process;
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Select};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::modules::{
|
||||
account::payload::MinimalAccount,
|
||||
cli::BichonCtlConfig,
|
||||
users::{permissions::Permission, view::UserView},
|
||||
};
|
||||
|
||||
pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &ColorfulTheme) -> u64 {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/v1/current-user", config.base_url);
|
||||
|
||||
let response = match client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"\n{} {}",
|
||||
style("✘ Network Error:").red().bold(),
|
||||
"Could not connect to Bichon service."
|
||||
);
|
||||
eprintln!("{} {}", style("Details:").dim(), e);
|
||||
eprintln!(
|
||||
"\n{} Please check if the Base URL is correct and the server is running.",
|
||||
style("Tip:").cyan()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "No error detail provided".to_string());
|
||||
|
||||
eprintln!(
|
||||
"\n{} Server returned an error (Status: {})",
|
||||
style("✘ API Error:").red().bold(),
|
||||
style(status).yellow()
|
||||
);
|
||||
|
||||
if status == 401 {
|
||||
eprintln!(
|
||||
"{} Your API Token seems to be invalid or expired.",
|
||||
style("Context:").dim()
|
||||
);
|
||||
} else if status == 404 {
|
||||
eprintln!(
|
||||
"{} The endpoint was not found. Please check your Base URL.",
|
||||
style("Context:").dim()
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("{} {}", style("Response:").dim(), error_body);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let user: UserView = response.json().await.expect("Failed to parse user data");
|
||||
println!("Welcome, {}!", style(&user.username).cyan());
|
||||
|
||||
let account_list_url = format!(
|
||||
"{}/api/v1/minimal-account-list?only_nosync=true",
|
||||
config.base_url
|
||||
);
|
||||
let acc_response = client
|
||||
.get(&account_list_url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to fetch account list");
|
||||
|
||||
if !acc_response.status().is_success() {
|
||||
panic!(
|
||||
"Failed to retrieve accounts. Status: {}",
|
||||
acc_response.status()
|
||||
);
|
||||
}
|
||||
|
||||
let accounts: Vec<MinimalAccount> = acc_response
|
||||
.json()
|
||||
.await
|
||||
.expect("Failed to parse minimal account list");
|
||||
|
||||
if accounts.is_empty() {
|
||||
println!(
|
||||
"\n{}",
|
||||
style("Error: No 'nosync' accounts found.").red().bold()
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
style("Mail import is only supported for 'nosync' type accounts.").dim()
|
||||
);
|
||||
println!(
|
||||
"Please create a new {} account in the Bichon web interface first.",
|
||||
style("Nosync").bold().yellow()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
let required_permission = Permission::DATA_IMPORT_BATCH;
|
||||
let mut selectable_accounts = Vec::new();
|
||||
let mut options = Vec::new();
|
||||
|
||||
for acc in accounts {
|
||||
let has_permission = if let Some(perms) = user.account_permissions.get(&acc.id) {
|
||||
perms.iter().any(|p| p == required_permission)
|
||||
} else {
|
||||
user.global_permissions
|
||||
.iter()
|
||||
.any(|p| p == Permission::DATA_MANAGE_ALL || p == Permission::ROOT)
|
||||
};
|
||||
|
||||
let status_prefix = if has_permission {
|
||||
style(" [READY] ").green()
|
||||
} else {
|
||||
style(" [NO PERMISSION] ").red()
|
||||
};
|
||||
|
||||
options.push(format!(
|
||||
"{}{} - {}",
|
||||
status_prefix,
|
||||
style(&acc.email).bold(),
|
||||
style(format!("ID: {}", acc.id)).dim()
|
||||
));
|
||||
|
||||
selectable_accounts.push((acc, has_permission));
|
||||
}
|
||||
|
||||
let selection = Select::with_theme(theme)
|
||||
.with_prompt("Select the target account for import")
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.max_length(10)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let (selected_acc, can_import) = &selectable_accounts[selection];
|
||||
|
||||
if !*can_import {
|
||||
eprintln!(
|
||||
"\n{} You do not have '{}' permission for account {}.",
|
||||
style("✘ Permission Denied:").red().bold(),
|
||||
style(required_permission).yellow(),
|
||||
style(&selected_acc.email).cyan()
|
||||
);
|
||||
eprintln!(
|
||||
"{} Please contact your administrator to upgrade your role for this account.",
|
||||
style("Tip:").dim()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} Targeting account: {}",
|
||||
style("✔").green(),
|
||||
style(&selected_acc.email).cyan().bold()
|
||||
);
|
||||
|
||||
selected_acc.id
|
||||
}
|
||||
133
src/modules/cli/eml/mod.rs
Normal file
133
src/modules/cli/eml/mod.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use mail_parser::MessageParser;
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::{
|
||||
base64_encode_url_safe,
|
||||
modules::cli::{sender::send_batch_request, BichonCtlConfig},
|
||||
};
|
||||
|
||||
pub async fn handle_eml_directory_import(
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
let root_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the ROOT directory to scan for .eml files")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if p.exists() && p.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Directory not found.")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let root_path = std::path::PathBuf::from(root_str);
|
||||
let mut tasks: HashMap<String, Vec<PathBuf>> = HashMap::new();
|
||||
println!(
|
||||
"{}",
|
||||
style("🔍 Scanning recursively using std::fs...").dim()
|
||||
);
|
||||
if let Err(e) = scan_dir(&root_path, &root_path, &mut tasks) {
|
||||
eprintln!("Error scanning directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if tasks.is_empty() {
|
||||
println!("{}", style("No .eml files found.").yellow());
|
||||
} else {
|
||||
process_and_upload(config, account_id, tasks).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_dir(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
tasks: &mut HashMap<String, Vec<PathBuf>>,
|
||||
) -> std::io::Result<()> {
|
||||
if current.is_dir() {
|
||||
for entry in fs::read_dir(current)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
scan_dir(root, &path, tasks)?;
|
||||
} else if path.is_file() {
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("eml") {
|
||||
let rel_path = path.strip_prefix(root).unwrap_or(Path::new(""));
|
||||
let mailbox_name = rel_path
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
let folder = if mailbox_name.is_empty() {
|
||||
"Inbox".to_string()
|
||||
} else {
|
||||
mailbox_name
|
||||
};
|
||||
tasks.entry(folder).or_insert_with(|| Vec::new()).push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_and_upload(
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
tasks: HashMap<String, Vec<PathBuf>>,
|
||||
) {
|
||||
let client = Client::new();
|
||||
let batch_size = 50;
|
||||
|
||||
for (mailbox, files) in tasks {
|
||||
println!("\n🚀 Processing mailbox: {}", style(&mailbox).cyan().bold());
|
||||
|
||||
let mut current_batch = Vec::new();
|
||||
|
||||
for file_path in files {
|
||||
let body = match fs::read(&file_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {} Failed to read file {:?}: {}",
|
||||
style("✘").red(),
|
||||
file_path,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if MessageParser::new().parse(&body).is_some() {
|
||||
let b64_content = base64_encode_url_safe!(&body);
|
||||
current_batch.push(b64_content);
|
||||
|
||||
if current_batch.len() >= batch_size {
|
||||
let to_send = current_batch;
|
||||
current_batch = Vec::with_capacity(batch_size);
|
||||
send_batch_request(&client, config, account_id, &mailbox, to_send).await;
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
" {} Invalid format, skipping: {:?}",
|
||||
style("⚠").yellow(),
|
||||
file_path
|
||||
);
|
||||
}
|
||||
}
|
||||
if !current_batch.is_empty() {
|
||||
send_batch_request(&client, config, account_id, &mailbox, current_batch).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/modules/cli/mbox/gmail.rs
Normal file
43
src/modules/cli/mbox/gmail.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub fn determine_folder(labels_raw: &str) -> String {
|
||||
let mut status_blacklist = HashSet::new();
|
||||
status_blacklist.insert("Opened");
|
||||
status_blacklist.insert("Unread");
|
||||
status_blacklist.insert("Archived");
|
||||
|
||||
let all_labels: Vec<&str> = labels_raw
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if all_labels.is_empty() {
|
||||
return "Unknown".to_string();
|
||||
}
|
||||
|
||||
let filtered: Vec<&str> = all_labels
|
||||
.iter()
|
||||
.filter(|&&l| !status_blacklist.contains(l))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
match filtered.len() {
|
||||
// Case A: If all labels were status labels, fallback to the first original label
|
||||
0 => all_labels[0].to_string(),
|
||||
// Case B: If only one label remains, that's our target destination
|
||||
1 => filtered[0].to_string(),
|
||||
// Case C: Multiple labels remain (e.g., ["Inbox", "medium"])
|
||||
_ => {
|
||||
// Prioritize custom business labels by excluding generic locations like "Inbox" or "Sent"
|
||||
let business_label = filtered.iter().find(|&&l| l != "Inbox" && l != "Sent");
|
||||
|
||||
match business_label {
|
||||
// Return the first non-generic label found
|
||||
Some(label) => label.to_string(),
|
||||
// If only generic labels remain (e.g., ["Sent", "Inbox"]), pick the first available
|
||||
None => filtered[0].to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
151
src/modules/cli/mbox/mod.rs
Normal file
151
src/modules/cli/mbox/mod.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::base64_encode_url_safe;
|
||||
use crate::modules::cli::mbox::gmail::determine_folder;
|
||||
use crate::modules::cli::mbox::reader::MboxFile;
|
||||
use crate::modules::cli::sender::send_batch_request;
|
||||
use crate::modules::cli::BichonCtlConfig;
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use dialoguer::{Confirm, Select};
|
||||
use mail_parser::MessageParser;
|
||||
use reqwest::Client;
|
||||
|
||||
pub mod gmail;
|
||||
pub mod reader;
|
||||
|
||||
pub async fn handle_mbox_single_file_import(
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
let path_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the path to your SINGLE .mbox file")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if !p.exists() {
|
||||
return Err("The specified path does not exist.");
|
||||
}
|
||||
if !p.is_file() {
|
||||
return Err("MBOX mode requires a SINGLE file, not a directory.");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let mbox_path = PathBuf::from(path_str);
|
||||
|
||||
let options = vec![
|
||||
"Use labels from mail headers (X-Gmail-Labels)",
|
||||
"Specify a single target folder for all emails",
|
||||
];
|
||||
|
||||
let selection = Select::with_theme(theme)
|
||||
.with_prompt("How should we determine the target folder?")
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let target_folder: Option<String> = match selection {
|
||||
0 => None,
|
||||
1 => {
|
||||
let folder: String = Input::with_theme(theme)
|
||||
.with_prompt("Target folder name")
|
||||
.default("INBOX".into())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
Some(folder)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(ref folder) = target_folder {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Mode: Fixed folder ({})", folder)).dim()
|
||||
);
|
||||
} else {
|
||||
println!("{}", style("Mode: Dynamic (header-based)").dim());
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n{} Ready to process MBOX file: {}",
|
||||
style("✔").green(),
|
||||
style(mbox_path.display()).cyan()
|
||||
);
|
||||
|
||||
if let Ok(meta) = std::fs::metadata(&mbox_path) {
|
||||
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Processing file: {:.1} MB", size_mb)).dim()
|
||||
);
|
||||
}
|
||||
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt("Start importing?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
run_import(account_id, &mbox_path, config, target_folder).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_import(
|
||||
account_id: u64,
|
||||
mbox_path: &PathBuf,
|
||||
config: &BichonCtlConfig,
|
||||
target_folder: Option<String>,
|
||||
) {
|
||||
let client = Client::new();
|
||||
let mbox = match MboxFile::from_file(mbox_path) {
|
||||
Ok(mbox) => mbox,
|
||||
Err(err) => {
|
||||
println!("Skipping invalid MBOX: {} ({})", mbox_path.display(), err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut folder_buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let batch_limit = 50;
|
||||
|
||||
println!("Starting import process...");
|
||||
|
||||
for e in mbox.iter() {
|
||||
let body = e.data;
|
||||
let message = MessageParser::new().parse(body).unwrap();
|
||||
|
||||
let folder_name = match target_folder {
|
||||
Some(ref folder_name) => folder_name.clone(),
|
||||
None => {
|
||||
let labels = message
|
||||
.header("X-Gmail-Labels")
|
||||
.and_then(|h| h.as_text())
|
||||
.unwrap_or("Inbox");
|
||||
determine_folder(labels)
|
||||
}
|
||||
};
|
||||
let b64_eml = base64_encode_url_safe!(&body);
|
||||
let buffer = folder_buffers
|
||||
.entry(folder_name.clone())
|
||||
.or_insert_with(|| Vec::new());
|
||||
buffer.push(b64_eml);
|
||||
|
||||
if buffer.len() >= batch_limit {
|
||||
let emls_to_send = folder_buffers.remove(&folder_name).unwrap();
|
||||
send_batch_request(&client, config, account_id, &folder_name, emls_to_send).await;
|
||||
}
|
||||
}
|
||||
|
||||
for (folder_name, emls) in folder_buffers {
|
||||
if !emls.is_empty() {
|
||||
send_batch_request(&client, config, account_id, &folder_name, emls).await;
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", style("Import completed successfully!").green().bold());
|
||||
}
|
||||
208
src/modules/cli/mbox/reader.rs
Normal file
208
src/modules/cli/mbox/reader.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use memmap2::Mmap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct MboxFile {
|
||||
map: Mmap,
|
||||
}
|
||||
|
||||
impl MboxFile {
|
||||
pub fn from_file(name: &Path) -> io::Result<Self> {
|
||||
let file = fs::File::open(name)?;
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.len() == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Empty MBOX file",
|
||||
));
|
||||
}
|
||||
let map = unsafe { Mmap::map(&file)? };
|
||||
Ok(Self { map })
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> MboxReader<'_> {
|
||||
MboxReader::new(&self.map)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Entry<'a> {
|
||||
pub offset: usize,
|
||||
pub data: &'a [u8],
|
||||
}
|
||||
|
||||
pub struct MboxReader<'a> {
|
||||
data: &'a [u8],
|
||||
len: usize,
|
||||
scan_pos: usize,
|
||||
body_start: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> MboxReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
len: data.len(),
|
||||
scan_pos: 0,
|
||||
body_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_from_line(&self, i: usize) -> bool {
|
||||
if i + 5 > self.len {
|
||||
return false;
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
&self.data[0..5] == b"From "
|
||||
} else {
|
||||
self.data[i - 1] == b'\n' && &self.data[i..i + 5] == b"From "
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_from_line(&self, mut i: usize) -> usize {
|
||||
while i < self.len && self.data[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
if i < self.len {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for MboxReader<'a> {
|
||||
type Item = Entry<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
while self.scan_pos < self.len {
|
||||
if self.is_from_line(self.scan_pos) {
|
||||
let from_pos = self.scan_pos;
|
||||
let body_pos = self.skip_from_line(from_pos);
|
||||
|
||||
if let Some(start) = self.body_start {
|
||||
let entry = Entry {
|
||||
offset: start,
|
||||
data: &self.data[start..from_pos],
|
||||
};
|
||||
self.body_start = Some(body_pos);
|
||||
self.scan_pos = body_pos;
|
||||
return Some(entry);
|
||||
} else {
|
||||
self.body_start = Some(body_pos);
|
||||
self.scan_pos = body_pos;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
self.scan_pos += 1;
|
||||
}
|
||||
if let Some(start) = self.body_start.take() {
|
||||
return Some(Entry {
|
||||
offset: start,
|
||||
data: &self.data[start..self.len],
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mail_parser::MessageParser;
|
||||
|
||||
use crate::modules::cli::mbox::gmail::determine_folder;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn collect_entries(data: &[u8]) -> Vec<&[u8]> {
|
||||
let reader = MboxReader::new(data);
|
||||
reader.map(|e| e.data).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_mails() {
|
||||
let data = b"From a\nmail1\nFrom b\nmail2\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e, vec![b"mail1\n", b"mail2\n"]);
|
||||
}
|
||||
#[test]
|
||||
fn no_trailing_newline() {
|
||||
let data = b"From a\nmail1";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e, vec![b"mail1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_inside_body() {
|
||||
let data = b"From a\nhello\nFrom is here\nbye\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_from_not_separator() {
|
||||
let data = b"From a\nhello From world\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realistic_mbox() {
|
||||
let data = b"From a\nH:1\n\nbody1\nFrom b\nH:2\n\nbody2\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body() {
|
||||
let data = b"From a\nFrom b\nbody\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e[0], b"");
|
||||
assert_eq!(e[1], b"body\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_from_line() {
|
||||
let data = b"From a\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0], b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_newlines() {
|
||||
let data = b"From a\r\nbody\r\nFrom b\r\nbody2\r\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_small_mails() {
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..1000 {
|
||||
data.extend_from_slice(b"From a\nx\n");
|
||||
}
|
||||
let e = collect_entries(&data);
|
||||
assert_eq!(e.len(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test11() {
|
||||
let mbox = MboxFile::from_file(Path::new("e:\\test.mbox")).unwrap();
|
||||
|
||||
for e in mbox.iter() {
|
||||
let body = e.data;
|
||||
|
||||
let message = MessageParser::new().parse(body).unwrap();
|
||||
let labels = message.header("X-Gmail-Labels").unwrap().as_text().unwrap();
|
||||
//println!("offset={} X-Gmail-Labels={:?}", e.offset, labels);
|
||||
println!(
|
||||
"X-Gmail-Labels={:?}, determine_folder={}",
|
||||
labels,
|
||||
determine_folder(labels)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/modules/cli/mod.rs
Normal file
37
src/modules/cli/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bichon_version;
|
||||
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod eml;
|
||||
pub mod mbox;
|
||||
pub mod pst;
|
||||
pub mod sender;
|
||||
pub mod thunderbird;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "bichonctl",
|
||||
author = "rustmailer",
|
||||
version = bichon_version!(),
|
||||
about = "A CLI tool to import email data into Bichon service"
|
||||
)]
|
||||
pub struct BichonCli {
|
||||
/// Path to the configuration file
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
default_value = "config.toml",
|
||||
value_name = "FILE",
|
||||
help = "Sets a custom config file"
|
||||
)]
|
||||
pub config: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct BichonCtlConfig {
|
||||
pub base_url: String,
|
||||
pub api_token: String,
|
||||
}
|
||||
45
src/modules/cli/pst/encoding/mod.rs
Normal file
45
src/modules/cli/pst/encoding/mod.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use compressed_rtf::*;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
pub fn decode_subject(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => {
|
||||
let offset = match value.buffer().first() {
|
||||
Some(1) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let buffer: Vec<_> = value
|
||||
.buffer()
|
||||
.iter()
|
||||
.skip(offset)
|
||||
.map(|&b| u16::from(b))
|
||||
.collect();
|
||||
Some(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
PropertyValue::Unicode(value) => {
|
||||
let offset = match value.buffer().first() {
|
||||
Some(1) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
Some(String::from_utf16_lossy(&value.buffer()[offset..]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option<String> {
|
||||
match code_page {
|
||||
20127 => {
|
||||
let buffer: Vec<_> = buffer.iter().map(|&b| u16::from(b)).collect();
|
||||
Some(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
_ => {
|
||||
let coding = codepage_strings::Coding::new(code_page).ok()?;
|
||||
Some(coding.decode(buffer).ok()?.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_rtf_compressed(buffer: &[u8]) -> Option<String> {
|
||||
decompress_rtf(buffer).ok()
|
||||
}
|
||||
458
src/modules/cli/pst/mod.rs
Normal file
458
src/modules/cli/pst/mod.rs
Normal file
@@ -0,0 +1,458 @@
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::Input;
|
||||
use mail_send::mail_builder::headers::text::Text;
|
||||
use mail_send::mail_builder::MessageBuilder;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
use crate::base64_encode_url_safe;
|
||||
use crate::modules::cli::pst::encoding::decode_subject;
|
||||
use crate::modules::cli::sender::send_batch_request;
|
||||
use crate::modules::cli::BichonCtlConfig;
|
||||
use dialoguer::Confirm;
|
||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||
use outlook_pst::messaging::folder::Folder;
|
||||
use outlook_pst::messaging::message::{Message, MessageProperties};
|
||||
use outlook_pst::ndb::node_id::NodeId;
|
||||
use reqwest::Client;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
|
||||
mod encoding;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailMetadata {
|
||||
pub message_id: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<Vec<String>>,
|
||||
pub cc: Option<Vec<String>>,
|
||||
pub bcc: Option<Vec<String>>,
|
||||
pub html: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailAttachment {
|
||||
pub name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) {
|
||||
let path_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the path to your SINGLE .pst file")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if !p.exists() {
|
||||
return Err("The specified path does not exist.");
|
||||
}
|
||||
|
||||
if !p.is_file() {
|
||||
return Err("PST mode requires a SINGLE file, not a directory.");
|
||||
}
|
||||
let is_pst = p
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("pst"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_pst {
|
||||
return Err("The selected file must have a .pst extension.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let pst_path = std::path::PathBuf::from(path_str);
|
||||
|
||||
println!(
|
||||
"\n{} Ready to process PST file: {}",
|
||||
console::style("✔").green(),
|
||||
console::style(pst_path.display()).cyan()
|
||||
);
|
||||
|
||||
if let Ok(meta) = std::fs::metadata(&pst_path) {
|
||||
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
|
||||
println!(
|
||||
"{}",
|
||||
console::style(format!("PST File Size: {:.1} MB", size_mb)).dim()
|
||||
);
|
||||
}
|
||||
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt("Start importing emails from this PST?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
parse_pst(pst_path, config, account_id).await;
|
||||
} else {
|
||||
println!("{}", console::style("Operation cancelled by user.").red());
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) {
|
||||
let client = Client::new();
|
||||
|
||||
let pst_store = match outlook_pst::open_store(&pst_path) {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Failed to open PST file: {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Could not find IPM_SUBTREE (Mailbox Root): {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
|
||||
Ok(folder) => folder,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Failed to open the root mailbox folder: {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
process_folder_recursively(&client, &ipm_subtree_folder, "", config, account_id).await;
|
||||
}
|
||||
|
||||
fn process_folder_recursively<'a>(
|
||||
client: &'a Client,
|
||||
folder: &'a Rc<dyn Folder>,
|
||||
parent_path: &'a str,
|
||||
config: &'a BichonCtlConfig,
|
||||
account_id: u64,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
|
||||
Box::pin(async move {
|
||||
let folder_name = folder
|
||||
.properties()
|
||||
.display_name()
|
||||
.unwrap_or_else(|_| "Unknown".to_string());
|
||||
|
||||
let current_path = if parent_path.is_empty() {
|
||||
folder_name
|
||||
} else {
|
||||
format!("{}/{}", parent_path, folder_name)
|
||||
};
|
||||
|
||||
println!(
|
||||
"{} {}",
|
||||
console::style("📁 Folder:").dim(),
|
||||
console::style(¤t_path).cyan()
|
||||
);
|
||||
|
||||
let mut emls_batch = Vec::new();
|
||||
|
||||
if let Some(contents_table) = folder.contents_table() {
|
||||
for row in contents_table.rows_matrix() {
|
||||
let store = folder.store().clone();
|
||||
|
||||
let entry_id = match store
|
||||
.properties()
|
||||
.make_entry_id(NodeId::from(u32::from(row.id())))
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {} Skip row {}: {:?}",
|
||||
console::style("⚠").yellow(),
|
||||
row.unique(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match store.open_message(&entry_id, None) {
|
||||
Ok(message) => match build_eml_base64(message) {
|
||||
Some(base64_eml) => emls_batch.push(base64_eml),
|
||||
None => {}
|
||||
},
|
||||
Err(e) => eprintln!(" {} Open error: {:?}", console::style("⚠").yellow(), e),
|
||||
}
|
||||
|
||||
if emls_batch.len() >= 50 {
|
||||
let batch = emls_batch.clone();
|
||||
emls_batch.clear();
|
||||
send_to_bichon(client, config, account_id, ¤t_path, batch).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !emls_batch.is_empty() {
|
||||
send_to_bichon(client, config, account_id, ¤t_path, emls_batch).await;
|
||||
}
|
||||
|
||||
if let Some(hierarchy_table) = folder.hierarchy_table() {
|
||||
for row in hierarchy_table.rows_matrix() {
|
||||
let node = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
|
||||
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
|
||||
process_folder_recursively(
|
||||
client,
|
||||
&sub_folder,
|
||||
¤t_path,
|
||||
config,
|
||||
account_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
|
||||
let properties = message.properties();
|
||||
|
||||
let mut builder = MessageBuilder::new();
|
||||
if let Some(sub) = extract_subject(properties) {
|
||||
builder = builder.subject(sub);
|
||||
}
|
||||
if let Some(mid) = extract_string_property(properties, 0x1035) {
|
||||
builder = builder.message_id(mid);
|
||||
}
|
||||
if let Some(irt) = extract_string_property(properties, 0x1042) {
|
||||
builder = builder.in_reply_to(irt);
|
||||
}
|
||||
|
||||
if let Some(refs) = extract_string_property(properties, 0x1039) {
|
||||
builder = builder.header("References", Text::new(refs));
|
||||
}
|
||||
|
||||
if let Some(cid_val) = properties.get(0x3013) {
|
||||
if let PropertyValue::Binary(bin) = cid_val {
|
||||
builder = builder.header(
|
||||
"X-Bichon-Conversation-ID",
|
||||
Text::new(hex::encode(bin.buffer())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let from = extract_string_property(properties, 0x5D01)
|
||||
.or_else(|| extract_string_property(properties, 0x5D02))
|
||||
.or_else(|| extract_string_property(properties, 0x0C1F));
|
||||
|
||||
if let Some(f) = from {
|
||||
builder = builder.from(f);
|
||||
}
|
||||
|
||||
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
|
||||
let dt = filetime_to_datetime(filetime).timestamp();
|
||||
builder = builder.date(dt);
|
||||
}
|
||||
|
||||
let (to, cc, bcc) = extract_recipients_list(&message);
|
||||
if !to.is_empty() {
|
||||
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !cc.is_empty() {
|
||||
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !bcc.is_empty() {
|
||||
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
if let Some(html) = extract_html(properties) {
|
||||
builder = builder.html_body(html);
|
||||
}
|
||||
|
||||
if let Some(text) = extract_text(properties) {
|
||||
builder = builder.text_body(text);
|
||||
}
|
||||
|
||||
if let Some(attachment_table) = message.attachment_table() {
|
||||
for row in attachment_table.rows_matrix() {
|
||||
let node_id = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
|
||||
let att_props = attachment.properties();
|
||||
let name = extract_attachment_string_property(att_props, 0x3707);
|
||||
let mime = extract_attachment_string_property(att_props, 0x370E)
|
||||
.unwrap_or_else(|| "application/octet-stream".into());
|
||||
let cid = extract_attachment_string_property(att_props, 0x3712);
|
||||
let is_inline = att_props
|
||||
.get(0x3714)
|
||||
.and_then(|val| {
|
||||
if let PropertyValue::Integer32(f) = val {
|
||||
Some(f)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|flag| (flag & 0x4) != 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
|
||||
let data = bin.buffer().to_vec();
|
||||
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
|
||||
|
||||
if is_inline && cid.is_some() {
|
||||
let content_id = cid.unwrap();
|
||||
builder = builder.inline(mime, content_id, data);
|
||||
} else {
|
||||
builder = builder.attachment(mime, file_name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match builder.write_to_vec() {
|
||||
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to generate EML: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
|
||||
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
|
||||
let nsecs = (filetime % 10_000_000) * 100;
|
||||
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
|
||||
}
|
||||
|
||||
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let mut to = Vec::new();
|
||||
let mut cc = Vec::new();
|
||||
let mut bcc = Vec::new();
|
||||
|
||||
let recipient_table = message.recipient_table();
|
||||
let context = recipient_table.context();
|
||||
|
||||
for row in recipient_table.rows_matrix() {
|
||||
if let Ok(cols) = row.columns(context) {
|
||||
let mut r_type = 0;
|
||||
let mut email = String::new();
|
||||
|
||||
for (col, val) in context.columns().iter().zip(cols) {
|
||||
let prop_val = val
|
||||
.as_ref()
|
||||
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
|
||||
match col.prop_id() {
|
||||
0x0C15 => {
|
||||
if let Some(PropertyValue::Integer32(t)) = prop_val {
|
||||
r_type = t;
|
||||
}
|
||||
}
|
||||
0x39FE | 0x3003 => {
|
||||
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
|
||||
email = s;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !email.is_empty() {
|
||||
match r_type {
|
||||
1 => to.push(email),
|
||||
2 => cc.push(email),
|
||||
3 => bcc.push(email),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(to, cc, bcc)
|
||||
}
|
||||
|
||||
async fn send_to_bichon(
|
||||
client: &Client,
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
folder_path: &str,
|
||||
emls: Vec<String>,
|
||||
) {
|
||||
send_batch_request(client, config, account_id, folder_path, emls).await;
|
||||
}
|
||||
|
||||
fn extract_subject(props: &MessageProperties) -> Option<String> {
|
||||
props.get(0x0037).and_then(|val| decode_subject(val))
|
||||
}
|
||||
|
||||
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_attachment_string_property(
|
||||
properties: &AttachmentProperties,
|
||||
prop_id: u16,
|
||||
) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_string(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_text(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1000).and_then(extract_string).or_else(|| {
|
||||
properties.get(0x1009).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_html(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1013).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => {
|
||||
let code_page = properties
|
||||
.get(0x3FDE)
|
||||
.and_then(|v| {
|
||||
if let PropertyValue::Integer32(cpid) = v {
|
||||
Some(*cpid as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(65001);
|
||||
encoding::decode_html_body(value.buffer(), code_page)
|
||||
}
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
|
||||
for &prop_id in prop_ids {
|
||||
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
|
||||
return Some(*value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
54
src/modules/cli/sender.rs
Normal file
54
src/modules/cli/sender.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use console::style;
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::modules::{cli::BichonCtlConfig, import::BatchEmlRequest};
|
||||
|
||||
pub async fn send_batch_request(
|
||||
client: &Client,
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
folder: &str,
|
||||
emls: Vec<String>,
|
||||
) {
|
||||
let url = format!("{}/api/v1/import", config.base_url);
|
||||
let payload = BatchEmlRequest {
|
||||
account_id,
|
||||
mail_folder: folder.to_string(),
|
||||
emls,
|
||||
};
|
||||
|
||||
let count = payload.emls.len();
|
||||
|
||||
match client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(res) if res.status().is_success() => {
|
||||
println!(
|
||||
" {} Sent {} emails to [{}]",
|
||||
style("✔").green(),
|
||||
count,
|
||||
folder
|
||||
);
|
||||
}
|
||||
Ok(res) => {
|
||||
eprintln!(
|
||||
" {} Failed to send to [{}]. Status: {}",
|
||||
style("✘").red(),
|
||||
folder,
|
||||
res.status()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {} Network error on [{}]: {}",
|
||||
style("✘").red(),
|
||||
folder,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/modules/cli/thunderbird/mod.rs
Normal file
113
src/modules/cli/thunderbird/mod.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use crate::modules::cli::{mbox::run_import, BichonCtlConfig};
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||
|
||||
pub async fn handle_thunderbird_import(
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
let root_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter your Thunderbird Mail/ImapMail directory")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if p.exists() && p.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Directory not found.")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let root_path = std::path::PathBuf::from(&root_str);
|
||||
println!("{}", style("🔍 Scanning Thunderbird structure...").dim());
|
||||
|
||||
let mut mbox_tasks: HashMap<String, PathBuf> = HashMap::new();
|
||||
|
||||
fn scan_thunderbird_dir(
|
||||
root: &std::path::Path,
|
||||
current: &std::path::Path,
|
||||
tasks: &mut HashMap<String, PathBuf>,
|
||||
) {
|
||||
if let Ok(entries) = std::fs::read_dir(current) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
||||
|
||||
if path.is_dir() {
|
||||
scan_thunderbird_dir(root, &path, tasks);
|
||||
} else {
|
||||
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
match extension {
|
||||
"msf" | "dat" | "html" | "json" | "txt" | "sqlite" => continue,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if file_name == "filterlog.html" || file_name == "msgFilterRules.dat" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !extension.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(rel) = path.strip_prefix(root) {
|
||||
let mailbox = rel.to_string_lossy().replace(".sbd", "").replace('\\', "/");
|
||||
tasks.insert(mailbox, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan_thunderbird_dir(&root_path, &root_path, &mut mbox_tasks);
|
||||
if mbox_tasks.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
style("No mailboxes found in the specified directory.").yellow()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n{}", style("🔍 Scanned Mailboxes:").bold().underlined());
|
||||
let mut sorted_keys: Vec<_> = mbox_tasks.keys().collect();
|
||||
sorted_keys.sort();
|
||||
|
||||
for name in &sorted_keys {
|
||||
let path = &mbox_tasks[*name];
|
||||
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
let size_mb = file_size as f64 / 1024.0 / 1024.0;
|
||||
|
||||
println!(
|
||||
" {} {} ({:.2} MB)",
|
||||
style("•").dim(),
|
||||
style(name).cyan(),
|
||||
size_mb
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
let prompt = format!("Ready to import {} mailboxes. Proceed?", mbox_tasks.len());
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt(prompt)
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
for (mailbox_name, mbox_file) in mbox_tasks {
|
||||
println!("\n🚀 Importing: {}", style(&mailbox_name).cyan().bold());
|
||||
run_import(account_id, &mbox_file, config, Some(mailbox_name)).await;
|
||||
}
|
||||
println!(
|
||||
"\n{}",
|
||||
style("✨ All mailboxes imported successfully!")
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
} else {
|
||||
println!("{}", style("Import cancelled.").yellow());
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use crate::{
|
||||
modules::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
token::AccessTokenModel,
|
||||
users::{permissions::Permission, role::UserRole, BichonUser},
|
||||
users::{permissions::Permission, role::UserRole, UserModel},
|
||||
utils::rate_limit::RATE_LIMITER_MANAGER,
|
||||
},
|
||||
raise_error,
|
||||
@@ -74,7 +74,7 @@ impl<E: Endpoint> Endpoint for ApiGuardEndpoint<E> {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClientContext {
|
||||
pub ip_addr: Option<IpAddr>,
|
||||
pub user: BichonUser,
|
||||
pub user: UserModel,
|
||||
}
|
||||
|
||||
impl ClientContext {
|
||||
|
||||
@@ -16,13 +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::error::BichonResult;
|
||||
|
||||
pub mod controller;
|
||||
pub mod executors;
|
||||
pub mod status;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait Initialize {
|
||||
async fn initialize() -> BichonResult<()>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::modules::cache::imap::MAILBOX_MODELS;
|
||||
use crate::modules::error::{code::ErrorCode, BichonError};
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::modules::settings::dir::DATA_DIR_MANAGER;
|
||||
use crate::modules::users::UserModel;
|
||||
use crate::modules::{database::META_MODELS, error::BichonResult};
|
||||
use crate::raise_error;
|
||||
use native_db::{Builder, Database};
|
||||
@@ -73,6 +74,8 @@ impl DatabaseManager {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.migrate::<AccountModel>()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.migrate::<UserModel>()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::modules::settings::proxy::Proxy;
|
||||
use crate::modules::settings::system::SystemSetting;
|
||||
use crate::modules::token::AccessTokenModel;
|
||||
use crate::modules::users::role::UserRole;
|
||||
use crate::modules::users::BichonUser;
|
||||
use crate::modules::users::{BichonUser, BichonUserV2};
|
||||
use crate::raise_error;
|
||||
use db_type::{KeyOptions, ToKeyDefinition};
|
||||
use itertools::Itertools;
|
||||
@@ -73,6 +73,7 @@ impl ModelsAdapter {
|
||||
self.register_model::<Proxy>();
|
||||
self.register_model::<UserRole>();
|
||||
self.register_model::<BichonUser>();
|
||||
self.register_model::<BichonUserV2>();
|
||||
self.register_model::<AccessTokenModel>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
|
||||
let attachments: Vec<String> = message
|
||||
.attachments()
|
||||
.filter(|att| {
|
||||
let disp = att.content_disposition();
|
||||
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
|
||||
let has_filename = att.attachment_name().is_some();
|
||||
has_filename && !is_inline
|
||||
})
|
||||
.filter_map(|att| att.attachment_name())
|
||||
.map(|name| name.to_string())
|
||||
.collect();
|
||||
@@ -195,6 +201,12 @@ pub fn extract_envelope_from_eml(
|
||||
|
||||
let attachments: Vec<String> = message
|
||||
.attachments()
|
||||
.filter(|att| {
|
||||
let disp = att.content_disposition();
|
||||
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
|
||||
let has_filename = att.attachment_name().is_some();
|
||||
has_filename && !is_inline
|
||||
})
|
||||
.filter_map(|att| att.attachment_name())
|
||||
.map(|name| name.to_string())
|
||||
.collect();
|
||||
|
||||
@@ -79,12 +79,26 @@ 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: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
start_uid: u64,
|
||||
before: Option<&str>
|
||||
before: Option<&str>,
|
||||
) -> BichonResult<()> {
|
||||
assert!(start_uid > 0, "start_uid must be greater than 0");
|
||||
|
||||
@@ -93,12 +107,7 @@ impl ImapExecutor {
|
||||
None => format!("UID {start_uid}:*"),
|
||||
};
|
||||
|
||||
let uid_list = self
|
||||
.uid_search(
|
||||
&mailbox.encoded_name(),
|
||||
&query,
|
||||
)
|
||||
.await?;
|
||||
let uid_list = self.uid_search(&mailbox.encoded_name(), &query).await?;
|
||||
|
||||
let len = uid_list.len();
|
||||
if len == 0 {
|
||||
|
||||
@@ -100,7 +100,6 @@ impl ImportEmls {
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -16,6 +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::HashSet;
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::cache::imap::mailbox::MailBox;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
@@ -155,9 +157,9 @@ impl Envelope {
|
||||
let id = create_hash(account_id, &message_id);
|
||||
let full_text = extract_string_field(doc, fields.f_text)?;
|
||||
|
||||
// Take up to the first 120 characters as a preview;
|
||||
let preview = if full_text.chars().count() > 120 {
|
||||
full_text.chars().take(120).collect::<String>() + "..."
|
||||
// Take up to the first 500 characters as a preview;
|
||||
let preview = if full_text.chars().count() > 500 {
|
||||
full_text.chars().take(500).collect::<String>() + "..."
|
||||
} else {
|
||||
full_text
|
||||
};
|
||||
@@ -204,3 +206,28 @@ impl Envelope {
|
||||
Ok(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
|
||||
let fields = SchemaTools::envelope_fields();
|
||||
let mut all_contacts = HashSet::new();
|
||||
|
||||
if let Ok(from_val) = extract_string_field(doc, fields.f_from) {
|
||||
if !from_val.is_empty() {
|
||||
all_contacts.insert(from_val);
|
||||
}
|
||||
}
|
||||
|
||||
let multi_fields = [fields.f_to, fields.f_cc, fields.f_bcc];
|
||||
|
||||
for field in multi_fields {
|
||||
if let Ok(vals) = extract_vec_string_field(doc, field) {
|
||||
for v in vals {
|
||||
if !v.is_empty() {
|
||||
all_contacts.insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_contacts)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::modules::message::tags::TagCount;
|
||||
use crate::modules::{
|
||||
indexer::envelope::extract_contacts,
|
||||
message::{search::SortBy, tags::TagCount},
|
||||
};
|
||||
use crate::{
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
@@ -34,8 +37,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,
|
||||
},
|
||||
@@ -57,7 +60,10 @@ use tantivy::{
|
||||
AggregationCollector, Key,
|
||||
},
|
||||
collector::{Count, FacetCollector, TopDocs},
|
||||
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
|
||||
query::{
|
||||
AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery,
|
||||
TermQuery,
|
||||
},
|
||||
schema::{Facet, IndexRecordOption, Value},
|
||||
store::{Compressor, ZstdCompressor},
|
||||
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
|
||||
@@ -70,6 +76,7 @@ use tokio::{
|
||||
sync::{mpsc, Mutex},
|
||||
task,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub static ENVELOPE_INDEX_MANAGER: LazyLock<EnvelopeIndexManager> =
|
||||
LazyLock::new(EnvelopeIndexManager::new);
|
||||
@@ -182,13 +189,23 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
|
||||
fn open_or_create_index(index_dir: &PathBuf) -> Index {
|
||||
if !index_dir.exists() {
|
||||
let need_create = !index_dir.exists()
|
||||
|| index_dir
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_none())
|
||||
.unwrap_or(true);
|
||||
if need_create {
|
||||
info!(
|
||||
"Email index not found or empty, creating new index at {}",
|
||||
index_dir.display()
|
||||
);
|
||||
std::fs::create_dir_all(&index_dir).unwrap_or_else(|e| {
|
||||
panic!("Failed to create index directory {:?}: {}", index_dir, e)
|
||||
});
|
||||
Index::create_in_dir(&index_dir, SchemaTools::envelope_schema())
|
||||
.unwrap_or_else(|e| panic!("Failed to create index in {:?}: {}", index_dir, e))
|
||||
} else {
|
||||
info!("Opening existing email index at {}", index_dir.display());
|
||||
open(&index_dir)
|
||||
}
|
||||
}
|
||||
@@ -306,11 +323,9 @@ impl EnvelopeIndexManager {
|
||||
(f.f_bcc, &filter.bcc),
|
||||
] {
|
||||
if let Some(ref v) = opt_value {
|
||||
let term = Term::from_field_text(field, v);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) {
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,21 +342,19 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
|
||||
if let Some(ref name) = filter.attachment_name {
|
||||
let term = Term::from_field_text(f.f_attachments, name);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) {
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
@@ -351,20 +364,28 @@ impl EnvelopeIndexManager {
|
||||
subqueries.push((Occur::Must, Box::new(q)));
|
||||
}
|
||||
|
||||
if let Some(account_id) = filter.account_id {
|
||||
let term = Term::from_field_u64(f.f_account_id, account_id);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Some(account_ids) = filter.account_ids {
|
||||
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||
for id in account_ids {
|
||||
let term = Term::from_field_u64(f.f_account_id, id);
|
||||
should_queries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
}
|
||||
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
|
||||
}
|
||||
|
||||
if let Some(mailbox_id) = filter.mailbox_id {
|
||||
let term = Term::from_field_u64(f.f_mailbox_id, mailbox_id);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Some(mailbox_ids) = filter.mailbox_ids {
|
||||
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||
for id in mailbox_ids {
|
||||
let term = Term::from_field_u64(f.f_mailbox_id, id);
|
||||
should_queries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
}
|
||||
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
|
||||
}
|
||||
|
||||
let start_bound = if let Some(from) = filter.min_size {
|
||||
@@ -520,6 +541,48 @@ impl EnvelopeIndexManager {
|
||||
Ok(all_facets)
|
||||
}
|
||||
|
||||
pub async fn get_all_contacts(
|
||||
&self,
|
||||
accounts: Option<HashSet<u64>>,
|
||||
) -> BichonResult<HashSet<String>> {
|
||||
let searcher = self.create_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 contacts_set: HashSet<String> = HashSet::new();
|
||||
|
||||
let top_docs = searcher
|
||||
.search(&query, &TopDocs::with_limit(1_000_000))
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
for (_score, doc_address) in top_docs {
|
||||
let doc: TantivyDocument = searcher
|
||||
.doc_async(doc_address)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let contacts = extract_contacts(&doc).await?;
|
||||
for value in contacts {
|
||||
contacts_set.insert(value);
|
||||
}
|
||||
}
|
||||
Ok(contacts_set)
|
||||
}
|
||||
|
||||
pub async fn delete_envelopes_multi_account(
|
||||
&self,
|
||||
deletes: &HashMap<u64, Vec<u64>>, // HashMap<account_id, envelope_ids>
|
||||
@@ -621,6 +684,7 @@ impl EnvelopeIndexManager {
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
desc: bool,
|
||||
sort_by: SortBy,
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
assert!(page > 0, "Page number must be greater than 0");
|
||||
assert!(page_size > 0, "Page size must be greater than 0");
|
||||
@@ -653,17 +717,36 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
|
||||
let order = if desc { Order::Desc } else { Order::Asc };
|
||||
let mailbox_docs: Vec<(i64, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INTERNAL_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let mailbox_docs: Vec<DocAddress>;
|
||||
|
||||
match sort_by {
|
||||
SortBy::DATE => {
|
||||
let date_docs: Vec<(i64, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
mailbox_docs = date_docs.into_iter().map(|(_, addr)| addr).collect();
|
||||
}
|
||||
SortBy::SIZE => {
|
||||
let size_docs: Vec<(u64, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_SIZE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
mailbox_docs = size_docs.into_iter().map(|(_, addr)| addr).collect();
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for (_, doc_address) in mailbox_docs {
|
||||
for doc_address in mailbox_docs {
|
||||
let doc: TantivyDocument = searcher
|
||||
.doc_async(doc_address)
|
||||
.await
|
||||
@@ -721,7 +804,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();
|
||||
@@ -786,7 +869,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();
|
||||
@@ -808,6 +891,47 @@ impl EnvelopeIndexManager {
|
||||
})
|
||||
}
|
||||
|
||||
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>>,
|
||||
@@ -1006,7 +1130,7 @@ impl EnvelopeIndexManager {
|
||||
},
|
||||
"recent_30d_histogram": {
|
||||
"histogram": {
|
||||
"field": F_INTERNAL_DATE,
|
||||
"field": F_DATE,
|
||||
"interval": 86400000,
|
||||
"hard_bounds": {
|
||||
"min": week_ago_ms,
|
||||
@@ -1120,10 +1244,43 @@ impl EnvelopeIndexManager {
|
||||
{
|
||||
for entry in buckets {
|
||||
if let Key::U64(account_id) = &entry.key {
|
||||
top_accounts.push(Group {
|
||||
key: AccountModel::get(*account_id).await?.email,
|
||||
count: entry.doc_count,
|
||||
});
|
||||
match AccountModel::get(*account_id).await {
|
||||
Ok(account) => {
|
||||
top_accounts.push(Group {
|
||||
key: account.email,
|
||||
count: entry.doc_count,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
account_id = account_id,
|
||||
error = %e,
|
||||
"orphaned account index detected, scheduling cleanup"
|
||||
);
|
||||
let account_id = *account_id;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = ENVELOPE_INDEX_MANAGER
|
||||
.delete_account_envelopes(account_id)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
account_id = account_id,
|
||||
error = %e,
|
||||
"failed to cleanup envelope index"
|
||||
);
|
||||
}
|
||||
if let Err(e) =
|
||||
EML_INDEX_MANAGER.delete_account_envelopes(account_id).await
|
||||
{
|
||||
tracing::error!(
|
||||
account_id = account_id,
|
||||
error = %e,
|
||||
"failed to cleanup eml index"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1218,7 +1375,17 @@ impl EmlIndexManager {
|
||||
}
|
||||
|
||||
fn open_or_create_index(index_dir: &PathBuf) -> Index {
|
||||
if !index_dir.exists() {
|
||||
let need_create = !index_dir.exists()
|
||||
|| index_dir
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_none())
|
||||
.unwrap_or(true);
|
||||
|
||||
if need_create {
|
||||
info!(
|
||||
"Email data storage not found or empty, creating new mail storage at {}",
|
||||
index_dir.display()
|
||||
);
|
||||
std::fs::create_dir_all(&index_dir).unwrap_or_else(|e| {
|
||||
panic!("Failed to create index directory {:?}: {}", index_dir, e)
|
||||
});
|
||||
@@ -1234,6 +1401,10 @@ impl EmlIndexManager {
|
||||
.create_in_dir(&index_dir)
|
||||
.unwrap_or_else(|e| panic!("Failed to create index in {:?}: {}", index_dir, e))
|
||||
} else {
|
||||
info!(
|
||||
"Opening existing email data storage at {}",
|
||||
index_dir.display()
|
||||
);
|
||||
open(&index_dir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::logger::file::setup_file_logger;
|
||||
use crate::modules::logger::file::setup_file_logger;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use chrono::Local;
|
||||
use std::process;
|
||||
|
||||
38
src/modules/mailbox/delete.rs
Normal file
38
src/modules/mailbox/delete.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::modules::{
|
||||
cache::imap::mailbox::MailBox,
|
||||
error::BichonResult,
|
||||
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
};
|
||||
|
||||
pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResult<()> {
|
||||
let mailbox = MailBox::get(mailbox_id).await?;
|
||||
|
||||
let name = mailbox.name;
|
||||
let delimiter = mailbox.delimiter.unwrap_or("/".to_owned());
|
||||
let all_mailboxes = MailBox::list_all(account_id).await?;
|
||||
|
||||
let prefix = format!("{}{}", name, delimiter);
|
||||
let ids_to_delete: Vec<u64> = all_mailboxes
|
||||
.into_iter()
|
||||
.filter(|m| m.id == mailbox_id || m.name.starts_with(&prefix))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
|
||||
if ids_to_delete.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for id in &ids_to_delete {
|
||||
MailBox::delete(*id).await?;
|
||||
}
|
||||
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.delete_mailbox_envelopes(account_id, ids_to_delete.clone())
|
||||
.await?;
|
||||
|
||||
EML_INDEX_MANAGER
|
||||
.delete_mailbox_envelopes(account_id, ids_to_delete)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
||||
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
|
||||
use crate::modules::context::executors::MAIL_CONTEXT;
|
||||
@@ -64,8 +63,15 @@ pub async fn convert_names_to_mailboxes(
|
||||
for name in names.into_iter() {
|
||||
// Convert the name into a MailBox structure
|
||||
let mailbox_name = name.name().to_string();
|
||||
|
||||
let mut mailbox: MailBox = name.into();
|
||||
|
||||
tracing::debug!(
|
||||
raw = &mailbox_name,
|
||||
decoded = &mailbox.name,
|
||||
"mailbox name comparison"
|
||||
);
|
||||
|
||||
if contains_no_select(&mailbox.attributes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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 delete;
|
||||
pub mod list;
|
||||
|
||||
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(())
|
||||
}
|
||||
10
src/modules/message/contacts.rs
Normal file
10
src/modules/message/contacts.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct Contact {
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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/>.
|
||||
|
||||
|
||||
pub mod append;
|
||||
pub mod contacts;
|
||||
pub mod content;
|
||||
pub mod delete;
|
||||
pub mod list;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use poem_openapi::Object;
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
@@ -39,8 +39,8 @@ pub struct SearchFilter {
|
||||
pub bcc: Option<String>,
|
||||
pub since: Option<i64>,
|
||||
pub before: Option<i64>,
|
||||
pub account_id: Option<u64>,
|
||||
pub mailbox_id: Option<u64>,
|
||||
pub account_ids: Option<Vec<u64>>,
|
||||
pub mailbox_ids: Option<Vec<u64>>,
|
||||
pub min_size: Option<u64>,
|
||||
pub max_size: Option<u64>,
|
||||
pub message_id: Option<String>,
|
||||
@@ -49,11 +49,20 @@ pub struct SearchFilter {
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
|
||||
pub enum SortBy {
|
||||
#[default]
|
||||
DATE,
|
||||
SIZE,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct SearchRequest {
|
||||
filter: SearchFilter,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
sort_by: Option<SortBy>,
|
||||
desc: Option<bool>,
|
||||
}
|
||||
impl SearchRequest {
|
||||
pub fn validate(&self) -> BichonResult<()> {
|
||||
@@ -84,7 +93,8 @@ pub async fn search_messages_impl(
|
||||
request.filter,
|
||||
request.page,
|
||||
request.page_size,
|
||||
true,
|
||||
request.desc.unwrap_or(true),
|
||||
request.sort_by.unwrap_or(SortBy::DATE),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
pub mod account;
|
||||
pub mod autoconfig;
|
||||
pub mod cache;
|
||||
pub mod cli;
|
||||
pub mod common;
|
||||
pub mod context;
|
||||
pub mod dashboard;
|
||||
|
||||
@@ -32,7 +32,7 @@ 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::modules::users::BichonUser;
|
||||
use crate::modules::users::UserModel;
|
||||
use crate::raise_error;
|
||||
use poem_openapi::param::{Path, Query};
|
||||
use poem_openapi::payload::Json;
|
||||
@@ -131,7 +131,7 @@ impl AccountApi {
|
||||
let is_admin = context.user.is_admin().await;
|
||||
let sort_desc = desc.0.unwrap_or(true);
|
||||
|
||||
let user_map: HashMap<u64, BichonUser> = BichonUser::list_all()
|
||||
let user_map: HashMap<u64, UserModel> = UserModel::list_all()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| (u.id, u))
|
||||
@@ -221,10 +221,13 @@ impl AccountApi {
|
||||
)]
|
||||
async fn minimal_accounts_list(
|
||||
&self,
|
||||
only_nosync: Query<Option<bool>>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Vec<MinimalAccount>>> {
|
||||
let is_admin = context.user.is_admin().await;
|
||||
let minimal_list = AccountModel::minimal_list().await?;
|
||||
let only_nosync = only_nosync.0.unwrap_or_default();
|
||||
|
||||
let minimal_list = AccountModel::minimal_list(only_nosync).await?;
|
||||
if is_admin {
|
||||
return Ok(Json(minimal_list));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
use crate::modules::cache::imap::mailbox::MailBox;
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::mailbox::delete::delete_mailbox_impl;
|
||||
use crate::modules::mailbox::list::get_account_mailboxes;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
@@ -58,4 +59,32 @@ impl MailBoxApi {
|
||||
let remote = remote.0.unwrap_or(false);
|
||||
Ok(Json(get_account_mailboxes(account_id, remote).await?))
|
||||
}
|
||||
|
||||
/// Deletes a mailbox for the specified account.
|
||||
///
|
||||
/// Requires `DATA_DELETE` permission on the target account.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `account_id`: Account identifier.
|
||||
/// - `mailbox_id`: Mailbox identifier.
|
||||
///
|
||||
#[oai(
|
||||
path = "/delete-mailbox/:account_id/:mailbox_id",
|
||||
method = "delete",
|
||||
operation_id = "delete_mailbox"
|
||||
)]
|
||||
async fn delete_mailbox(
|
||||
&self,
|
||||
/// The unique identifier of the account.
|
||||
account_id: Path<u64>,
|
||||
mailbox_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
let mailbox_id = mailbox_id.0;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_DELETE)
|
||||
.await?;
|
||||
Ok(delete_mailbox_impl(account_id, mailbox_id).await?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::indexer::envelope::Envelope;
|
||||
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
|
||||
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
|
||||
use crate::modules::message::append::restore_emails;
|
||||
use crate::modules::message::append::RestoreMessagesRequest;
|
||||
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
|
||||
use crate::modules::message::delete::delete_messages_impl;
|
||||
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
|
||||
@@ -66,7 +68,7 @@ impl MessageApi {
|
||||
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",
|
||||
@@ -74,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>,
|
||||
@@ -90,7 +94,8 @@ impl MessageApi {
|
||||
))
|
||||
}
|
||||
|
||||
/// 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",
|
||||
@@ -112,7 +117,7 @@ impl MessageApi {
|
||||
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",
|
||||
@@ -140,9 +145,9 @@ impl MessageApi {
|
||||
))
|
||||
}
|
||||
|
||||
/// 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"
|
||||
)]
|
||||
@@ -151,19 +156,54 @@ impl MessageApi {
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the message to fetch.
|
||||
message_id: Query<u64>,
|
||||
message_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<FullMessageContent>> {
|
||||
let account_id = account_id.0;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
Ok(Json(retrieve_email_content(account_id, message_id.0).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"
|
||||
)]
|
||||
@@ -172,7 +212,7 @@ impl MessageApi {
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the message to download.
|
||||
message_id: Query<u64>,
|
||||
message_id: Path<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Attachment<Body>> {
|
||||
let account_id = account_id.0;
|
||||
@@ -189,9 +229,28 @@ impl MessageApi {
|
||||
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"
|
||||
)]
|
||||
@@ -200,7 +259,7 @@ impl MessageApi {
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the message containing the attachment.
|
||||
message_id: Query<u64>,
|
||||
message_id: Path<u64>,
|
||||
/// The filename of the attachment to download.
|
||||
name: Query<String>,
|
||||
context: ClientContext,
|
||||
@@ -210,10 +269,9 @@ impl MessageApi {
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
let message_id = message_id.0;
|
||||
let name = name.0.trim();
|
||||
let reader = EML_INDEX_MANAGER
|
||||
.get_attachment(account_id, message_id, name)
|
||||
.get_attachment(account_id, message_id.0, name)
|
||||
.await?;
|
||||
let body = Body::from_async_read(reader);
|
||||
let attachment = Attachment::new(body)
|
||||
@@ -265,4 +323,25 @@ impl MessageApi {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[oai(
|
||||
path = "/all-contacts",
|
||||
method = "get",
|
||||
operation_id = "get_all_contacts"
|
||||
)]
|
||||
async fn get_all_contacts(&self, context: ClientContext) -> ApiResult<Json<HashSet<String>>> {
|
||||
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_contacts(authorized_ids)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ 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<()> {
|
||||
@@ -96,11 +96,11 @@ impl SystemApi {
|
||||
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>> {
|
||||
|
||||
@@ -27,9 +27,9 @@ 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::role::{RoleType, UserRole};
|
||||
use crate::modules::users::view::UserView;
|
||||
use crate::modules::users::BichonUser;
|
||||
use crate::modules::users::UserModel;
|
||||
use poem::web::Path;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
@@ -100,11 +100,8 @@ impl UsersApi {
|
||||
.await?;
|
||||
let roles = UserRole::list_all().await?;
|
||||
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
|
||||
let users = BichonUser::list_all().await?;
|
||||
let users = users
|
||||
.into_iter()
|
||||
.map(|u| u.to_current_user(&role_lookup))
|
||||
.collect();
|
||||
let users = UserModel::list_all().await?;
|
||||
let users = users.into_iter().map(|u| u.to_view(&role_lookup)).collect();
|
||||
Ok(Json(users))
|
||||
}
|
||||
|
||||
@@ -140,7 +137,7 @@ impl UsersApi {
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
Ok(BichonUser::remove(id).await?)
|
||||
Ok(UserModel::remove(id).await?)
|
||||
}
|
||||
|
||||
#[oai(path = "/users", method = "post", operation_id = "create_user")]
|
||||
@@ -152,10 +149,10 @@ impl UsersApi {
|
||||
context
|
||||
.require_permission(None, Permission::USER_MANAGE)
|
||||
.await?;
|
||||
let user = BichonUser::create(payload.0).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_current_user(&role_lookup)))
|
||||
Ok(Json(user.to_view(&role_lookup)))
|
||||
}
|
||||
|
||||
#[oai(path = "/users/:id", method = "post", operation_id = "update_user")]
|
||||
@@ -180,7 +177,7 @@ impl UsersApi {
|
||||
update_data.account_access_map = None;
|
||||
update_data.acl = None;
|
||||
}
|
||||
Ok(BichonUser::update(target_id, update_data).await?)
|
||||
Ok(UserModel::update(target_id, update_data).await?)
|
||||
}
|
||||
|
||||
#[oai(
|
||||
@@ -191,7 +188,7 @@ impl UsersApi {
|
||||
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_current_user(&role_lookup)))
|
||||
Ok(Json(context.user.to_view(&role_lookup)))
|
||||
}
|
||||
|
||||
#[oai(
|
||||
@@ -214,4 +211,21 @@ impl UsersApi {
|
||||
|
||||
Ok(Json(minimal_list))
|
||||
}
|
||||
|
||||
#[oai(
|
||||
path = "/list-account-roles",
|
||||
method = "get",
|
||||
operation_id = "list_account_roles"
|
||||
)]
|
||||
async fn list_account_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
|
||||
context
|
||||
.require_permission(None, Permission::USER_VIEW)
|
||||
.await?;
|
||||
let all = UserRole::list_all().await?;
|
||||
Ok(Json(
|
||||
all.into_iter()
|
||||
.filter(|r| matches!(r.role_type, RoleType::Account))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::users::BichonUser;
|
||||
use crate::modules::users::UserModel;
|
||||
use poem::{handler, web::Json, IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use tracing::error;
|
||||
@@ -34,7 +34,7 @@ pub struct LoginPayload {
|
||||
#[handler]
|
||||
pub async fn login(payload: Json<LoginPayload>) -> Response {
|
||||
let payload = payload.0;
|
||||
match BichonUser::authenticate_user(payload.username, payload.password).await {
|
||||
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)
|
||||
|
||||
@@ -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 crate::modules::settings::io::check_dir_read_write;
|
||||
use clap::{builder::ValueParser, Parser, ValueEnum};
|
||||
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
|
||||
|
||||
@@ -46,16 +47,16 @@ pub struct Settings {
|
||||
)]
|
||||
pub bichon_http_port: i32,
|
||||
|
||||
/// The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1).
|
||||
/// The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
default_value = "0.0.0.0",
|
||||
help = "The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1). Required in cluster mode.",
|
||||
help = "The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
// Ensure the input is a valid IPv4 address
|
||||
if s.parse::<std::net::Ipv4Addr>().is_err() {
|
||||
return Err("The bind IP address must be a valid IPv4 address.".to_string());
|
||||
// Ensure the input is a valid IPv4 or IPv6 address
|
||||
if s.parse::<std::net::Ipv4Addr>().is_err() && s.parse::<std::net::Ipv6Addr>().is_err() {
|
||||
return Err("The bind IP address must be a valid IPv4 or IPv6 address.".to_string());
|
||||
}
|
||||
|
||||
// If the address is valid, return it
|
||||
@@ -64,7 +65,7 @@ pub struct Settings {
|
||||
)]
|
||||
pub bichon_bind_ip: Option<String>,
|
||||
|
||||
/// RustMail public URL (default: "http://localhost:15630")
|
||||
/// bichon public URL (default: "http://localhost:15630")
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "http://localhost:15630",
|
||||
@@ -160,20 +161,48 @@ pub struct Settings {
|
||||
help = "Set the file path for bichon database",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err("Path must be an absolute directory path".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(format!("Path {:?} does not exist", path));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(format!("Path {:?} is not a directory", path));
|
||||
return Err("'bichon_root_dir' must be an absolute directory path".to_string());
|
||||
}
|
||||
|
||||
check_dir_read_write(&path)?;
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_root_dir: String,
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
help = "Set the file path for email index directory",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err("'bichon_index_dir' must be an absolute directory path".to_string());
|
||||
}
|
||||
|
||||
check_dir_read_write(&path)?;
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_index_dir: Option<String>,
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
help = "Set the file path for email data directory",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err("'bichon_data_dir' must be an absolute directory path".to_string());
|
||||
}
|
||||
|
||||
check_dir_read_write(&path)?;
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_data_dir: Option<String>,
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
|
||||
@@ -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::context::Initialize;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::{
|
||||
@@ -35,7 +34,6 @@ const LOG_DIR: &str = "logs";
|
||||
const TLS_CERT: &str = "cert.pem";
|
||||
const TLS_KEY: &str = "key.pem";
|
||||
|
||||
|
||||
pub static DATA_DIR_MANAGER: LazyLock<DataDirManager> =
|
||||
LazyLock::new(|| DataDirManager::new(PathBuf::from(&SETTINGS.bichon_root_dir)));
|
||||
|
||||
@@ -49,7 +47,7 @@ pub struct DataDirManager {
|
||||
pub tls_key: PathBuf,
|
||||
pub envelope_dir: PathBuf,
|
||||
pub eml_dir: PathBuf,
|
||||
pub log_dir: PathBuf
|
||||
pub log_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Initialize for DataDirManager {
|
||||
@@ -66,6 +64,18 @@ impl Initialize for DataDirManager {
|
||||
|
||||
impl DataDirManager {
|
||||
pub fn new(root_dir: PathBuf) -> Self {
|
||||
let envelope_dir = if let Some(ref index_dir) = SETTINGS.bichon_index_dir {
|
||||
PathBuf::from(index_dir)
|
||||
} else {
|
||||
root_dir.join(ENVELOPE_DIR)
|
||||
};
|
||||
|
||||
let eml_dir = if let Some(ref data_dir) = SETTINGS.bichon_data_dir {
|
||||
PathBuf::from(data_dir)
|
||||
} else {
|
||||
root_dir.join(EML_DIR)
|
||||
};
|
||||
|
||||
Self {
|
||||
root_dir: root_dir.clone(),
|
||||
meta_db: root_dir.join(META_FILE),
|
||||
@@ -73,9 +83,9 @@ impl DataDirManager {
|
||||
tls_key: root_dir.join(TLS_KEY),
|
||||
tls_cert: root_dir.join(TLS_CERT),
|
||||
log_dir: root_dir.join(LOG_DIR),
|
||||
envelope_dir: root_dir.join(ENVELOPE_DIR),
|
||||
envelope_dir,
|
||||
temp_dir: root_dir.join(TMP_DIR),
|
||||
eml_dir: root_dir.join(EML_DIR),
|
||||
eml_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
src/modules/settings/io.rs
Normal file
41
src/modules/settings/io.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn check_dir_read_write(path: &Path) -> Result<(), String> {
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(path)
|
||||
.map_err(|e| format!("Cannot create directory {:?}: {}", path, e))?;
|
||||
}
|
||||
|
||||
if !path.is_dir() {
|
||||
return Err(format!("{:?} is not a directory", path));
|
||||
}
|
||||
|
||||
let test_file = path.join(".bichon_perm_test");
|
||||
|
||||
{
|
||||
let mut f = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&test_file)
|
||||
.map_err(|e| format!("Directory {:?} is not writable: {}", path, e))?;
|
||||
|
||||
f.write_all(b"test")
|
||||
.map_err(|e| format!("Directory {:?} is not writable: {}", path, e))?;
|
||||
}
|
||||
|
||||
{
|
||||
let mut buf = Vec::new();
|
||||
let mut f = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(&test_file)
|
||||
.map_err(|e| format!("Directory {:?} is not readable: {}", path, e))?;
|
||||
|
||||
f.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("Directory {:?} is not readable: {}", path, e))?;
|
||||
}
|
||||
let _ = fs::remove_file(&test_file);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -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::settings::cli::Settings;
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::modules::settings::cli::Settings;
|
||||
|
||||
pub mod cli;
|
||||
pub mod dir;
|
||||
pub mod io;
|
||||
pub mod proxy;
|
||||
pub mod system;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct SystemConfigurations {
|
||||
pub bichon_log_level: String,
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::modules::database::{
|
||||
use crate::modules::database::{insert_impl, list_all_impl, update_impl};
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::modules::token::view::AccessTokenResp;
|
||||
use crate::modules::users::BichonUser;
|
||||
use crate::modules::users::UserModel;
|
||||
use crate::raise_error;
|
||||
use crate::{
|
||||
generate_token, modules::error::BichonResult,
|
||||
@@ -180,7 +180,7 @@ impl AccessTokenModel {
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn resolve_user_from_token(token: &str) -> BichonResult<BichonUser> {
|
||||
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 {
|
||||
@@ -237,7 +237,7 @@ impl AccessTokenModel {
|
||||
.await?;
|
||||
}
|
||||
|
||||
let user = BichonUser::find(token.user_id)
|
||||
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)
|
||||
@@ -287,11 +287,11 @@ impl AccessTokenModel {
|
||||
}
|
||||
|
||||
pub async fn list_all_api_tokens() -> BichonResult<Vec<AccessTokenResp>> {
|
||||
let users = BichonUser::list_all().await?;
|
||||
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, BichonUser> = users.into_iter().map(|u| (u.id, u)).collect();
|
||||
let user_map: HashMap<u64, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
|
||||
|
||||
let resp = all
|
||||
.into_iter()
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
use crate::modules::{
|
||||
context::Initialize,
|
||||
error::BichonResult,
|
||||
users::{role::UserRole, BichonUser},
|
||||
users::{role::UserRole, UserModel},
|
||||
};
|
||||
|
||||
pub struct UserManager;
|
||||
@@ -27,6 +27,6 @@ pub struct UserManager;
|
||||
impl Initialize for UserManager {
|
||||
async fn initialize() -> BichonResult<()> {
|
||||
UserRole::ensure_default_roles_exists().await?;
|
||||
BichonUser::ensure_default_admin_exists().await
|
||||
UserModel::ensure_default_admin_exists().await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::modules::{
|
||||
database::{list_all_impl, manager::DB_MANAGER},
|
||||
error::BichonResult,
|
||||
users::BichonUser,
|
||||
users::UserModel,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
@@ -34,7 +34,7 @@ pub struct MinimalUser {
|
||||
|
||||
impl MinimalUser {
|
||||
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
|
||||
let all_users = list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?;
|
||||
let all_users = list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?;
|
||||
let minimal_list = all_users
|
||||
.into_iter()
|
||||
.map(|user| MinimalUser {
|
||||
|
||||
@@ -51,11 +51,15 @@ 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;
|
||||
@@ -92,9 +96,44 @@ pub struct BichonUser {
|
||||
pub acl: Option<AccessControl>,
|
||||
}
|
||||
|
||||
impl BichonUser {
|
||||
pub async fn list_all() -> BichonResult<Vec<BichonUser>> {
|
||||
Ok(list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?)
|
||||
#[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> {
|
||||
@@ -111,7 +150,7 @@ impl BichonUser {
|
||||
all_perms
|
||||
}
|
||||
|
||||
pub fn to_current_user(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
|
||||
pub fn to_view(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
|
||||
let global_roles_names = self
|
||||
.global_roles
|
||||
.iter()
|
||||
@@ -173,6 +212,8 @@ impl BichonUser {
|
||||
acl: self.acl,
|
||||
account_permissions,
|
||||
global_permissions,
|
||||
theme: self.theme,
|
||||
language: self.language,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,12 +228,12 @@ impl BichonUser {
|
||||
// 1. Try to get the existing admin user
|
||||
let admin = rw
|
||||
.get()
|
||||
.primary::<BichonUser>(DEFAULT_ADMIN_USER_ID)
|
||||
.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(BichonUser {
|
||||
rw.insert(UserModel {
|
||||
id: DEFAULT_ADMIN_USER_ID,
|
||||
username: "admin".into(),
|
||||
email: "placeholder@example.com".into(),
|
||||
@@ -209,6 +250,8 @@ impl BichonUser {
|
||||
updated_at: now,
|
||||
description: Some("System default administrator".into()),
|
||||
acl: None,
|
||||
theme: None,
|
||||
language: None,
|
||||
})
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
@@ -239,9 +282,9 @@ impl BichonUser {
|
||||
username: String,
|
||||
password: String,
|
||||
) -> BichonResult<LoginResult> {
|
||||
let user_option = secondary_find_impl::<BichonUser>(
|
||||
let user_option = secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserKey::username,
|
||||
BichonUserV2Key::username,
|
||||
username.clone(),
|
||||
)
|
||||
.await?;
|
||||
@@ -249,9 +292,9 @@ impl BichonUser {
|
||||
let user = match user_option {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
match secondary_find_impl::<BichonUser>(
|
||||
match secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserKey::email,
|
||||
BichonUserV2Key::email,
|
||||
username,
|
||||
)
|
||||
.await?
|
||||
@@ -262,6 +305,8 @@ impl BichonUser {
|
||||
success: false,
|
||||
error_message: Some("User or email not found.".to_string()),
|
||||
access_token: None,
|
||||
theme: None,
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -277,6 +322,8 @@ impl BichonUser {
|
||||
success: true,
|
||||
error_message: None,
|
||||
access_token: Some(new_token),
|
||||
theme: user.theme,
|
||||
language: user.language,
|
||||
})
|
||||
} else {
|
||||
warn!(
|
||||
@@ -287,6 +334,8 @@ impl BichonUser {
|
||||
success: false,
|
||||
error_message: Some("Incorrect password.".to_string()),
|
||||
access_token: None,
|
||||
theme: None,
|
||||
language: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -304,20 +353,22 @@ impl BichonUser {
|
||||
)
|
||||
),
|
||||
access_token: None,
|
||||
theme: None,
|
||||
language: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find(user_id: u64) -> BichonResult<Option<BichonUser>> {
|
||||
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::<BichonUser>(
|
||||
if secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserKey::username,
|
||||
BichonUserV2Key::username,
|
||||
username.to_string(),
|
||||
)
|
||||
.await?
|
||||
@@ -334,9 +385,9 @@ impl BichonUser {
|
||||
|
||||
pub async fn check_email_conflict(email: &str) -> BichonResult<()> {
|
||||
// Check email duplicate
|
||||
if secondary_find_impl::<BichonUser>(
|
||||
if secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserKey::email,
|
||||
BichonUserV2Key::email,
|
||||
email.to_string(),
|
||||
)
|
||||
.await?
|
||||
@@ -351,7 +402,7 @@ impl BichonUser {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create(request: UserCreateRequest) -> BichonResult<BichonUser> {
|
||||
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?;
|
||||
@@ -359,7 +410,7 @@ impl BichonUser {
|
||||
let password_hash = Some(encrypt!(&request.password)?);
|
||||
let now = utc_now!();
|
||||
|
||||
let user = BichonUser {
|
||||
let user = UserModel {
|
||||
id: id!(96),
|
||||
username: request.username,
|
||||
email: request.email,
|
||||
@@ -371,6 +422,8 @@ impl BichonUser {
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
account_access_map: request.account_access_map,
|
||||
theme: request.theme,
|
||||
language: request.language,
|
||||
};
|
||||
|
||||
let user_clone = user.clone();
|
||||
@@ -416,7 +469,7 @@ impl BichonUser {
|
||||
|
||||
delete_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get()
|
||||
.primary::<BichonUser>(id)
|
||||
.primary::<UserModel>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -444,18 +497,32 @@ impl BichonUser {
|
||||
|
||||
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;
|
||||
|
||||
if DEFAULT_ADMIN_USER_ID == id && request.global_roles.is_some() {
|
||||
return Err(raise_error!(
|
||||
format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id),
|
||||
ErrorCode::Forbidden
|
||||
));
|
||||
if is_default_admin {
|
||||
if let Some(roles) = request.global_roles.as_deref() {
|
||||
let is_valid = matches!(
|
||||
roles,
|
||||
[role] if *role == DEFAULT_ADMIN_ROLE_ID
|
||||
);
|
||||
|
||||
if !is_valid {
|
||||
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::<BichonUser>(
|
||||
let user_option = secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserKey::username,
|
||||
BichonUserV2Key::username,
|
||||
username.to_string(),
|
||||
)
|
||||
.await?;
|
||||
@@ -471,9 +538,9 @@ impl BichonUser {
|
||||
}
|
||||
|
||||
if let Some(email) = &request.email {
|
||||
let user_option = secondary_find_impl::<BichonUser>(
|
||||
let user_option = secondary_find_impl::<UserModel>(
|
||||
DB_MANAGER.meta_db(),
|
||||
BichonUserKey::email,
|
||||
BichonUserV2Key::email,
|
||||
email.to_string(),
|
||||
)
|
||||
.await?;
|
||||
@@ -492,7 +559,7 @@ impl BichonUser {
|
||||
DB_MANAGER.meta_db(),
|
||||
move |rw| {
|
||||
rw.get()
|
||||
.primary::<BichonUser>(id)
|
||||
.primary::<UserModel>(id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -531,18 +598,32 @@ impl BichonUser {
|
||||
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<BichonUser>> {
|
||||
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<UserModel>> {
|
||||
let all = Self::list_all().await?;
|
||||
let result: Vec<BichonUser> = all
|
||||
let result: Vec<UserModel> = all
|
||||
.into_iter()
|
||||
.filter(|e| e.account_access_map.contains_key(&account_id))
|
||||
.collect();
|
||||
@@ -560,7 +641,7 @@ impl BichonUser {
|
||||
for user in users {
|
||||
let current = rw
|
||||
.get()
|
||||
.primary::<BichonUser>(user.id)
|
||||
.primary::<UserModel>(user.id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -584,3 +665,41 @@ impl BichonUser {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,44 @@ use crate::{
|
||||
};
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
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 {
|
||||
@@ -176,6 +213,8 @@ pub struct UserCreateRequest {
|
||||
pub acl: Option<AccessControl>,
|
||||
pub avatar_base64: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
impl UserCreateRequest {
|
||||
@@ -183,9 +222,9 @@ impl UserCreateRequest {
|
||||
let username_len = self.username.len();
|
||||
|
||||
// 1. Username constraints
|
||||
if username_len < 5 {
|
||||
if username_len < 3 {
|
||||
return Err(raise_error!(
|
||||
"Username must be at least 5 characters long.".into(),
|
||||
"Username must be at least 3 characters long.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
@@ -204,9 +243,9 @@ impl UserCreateRequest {
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if password_len > 32 {
|
||||
if password_len > 256 {
|
||||
return Err(raise_error!(
|
||||
"Password cannot exceed 32 characters.".into(),
|
||||
"Password cannot exceed 256 characters.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
@@ -219,6 +258,9 @@ impl UserCreateRequest {
|
||||
));
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -301,15 +343,17 @@ pub struct UserUpdateRequest {
|
||||
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 {
|
||||
if len < 3 || len > 32 {
|
||||
return Err(raise_error!(
|
||||
"Username must be 5-32 characters.".into(),
|
||||
"Username must be 3-32 characters.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
@@ -317,14 +361,17 @@ impl UserUpdateRequest {
|
||||
|
||||
if let Some(password) = &self.password {
|
||||
let len = password.len();
|
||||
if len < 8 || len > 32 {
|
||||
if len < 8 || len > 256 {
|
||||
return Err(raise_error!(
|
||||
"Password must be 8-32 characters.".into(),
|
||||
"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();
|
||||
|
||||
@@ -53,7 +53,8 @@ impl Permission {
|
||||
/// 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).
|
||||
/// View the minimal user list, basic user profiles,
|
||||
/// including visibility into account-level roles (Managers and Admins).
|
||||
pub const USER_VIEW: &str = "user:view";
|
||||
|
||||
/// View and revoke all access tokens in the system.
|
||||
|
||||
@@ -49,4 +49,6 @@ pub struct UserView {
|
||||
pub updated_at: i64,
|
||||
/// Optional access control settings
|
||||
pub acl: Option<AccessControl>,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn decrypt_string(data: &str) -> BichonResult<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn internal_encrypt_string(
|
||||
pub fn internal_encrypt_string(
|
||||
password: &str,
|
||||
plaintext: &str,
|
||||
) -> Result<String, ring::error::Unspecified> {
|
||||
@@ -102,7 +102,7 @@ fn internal_encrypt_string(
|
||||
Ok(general_purpose::URL_SAFE.encode(&result))
|
||||
}
|
||||
|
||||
fn internal_decrypt_string(password: &str, data: &str) -> Result<String, ring::error::Unspecified> {
|
||||
pub fn internal_decrypt_string(password: &str, data: &str) -> Result<String, ring::error::Unspecified> {
|
||||
let data = general_purpose::URL_SAFE
|
||||
.decode(data)
|
||||
.map_err(|_| ring::error::Unspecified)?;
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||
"@react-spring/web": "^10.0.3",
|
||||
|
||||
6
web/pnpm-lock.yaml
generated
6
web/pnpm-lock.yaml
generated
@@ -86,6 +86,12 @@ importers:
|
||||
'@radix-ui/react-toast':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.1.10
|
||||
version: 1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-toggle-group':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
@@ -27,7 +27,7 @@ const baseURL = process.env.NODE_ENV === "production"
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL,
|
||||
timeout: 30000, // Timeout in milliseconds
|
||||
timeout: 60000, // Timeout in milliseconds
|
||||
headers: {
|
||||
"Content-Type": "application/json", // Explicitly setting Content-Type to application/json
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import axiosInstance from "@/api/axiosInstance";
|
||||
|
||||
|
||||
export interface MailboxData {
|
||||
account_id: number;
|
||||
attributes: { attr: string; extension: string | null }[];
|
||||
delimiter: string | null;
|
||||
exists: number;
|
||||
@@ -34,4 +35,10 @@ export interface MailboxData {
|
||||
export const list_mailboxes = async (accountId: number, remote: boolean) => {
|
||||
const response = await axiosInstance.get<MailboxData[]>(`/api/v1/list-mailboxes/${accountId}?remote=${remote}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const delete_mailbox = async (accountId: number, mailboxId: string) => {
|
||||
const response = await axiosInstance.delete(`/api/v1/delete-mailbox/${accountId}/${mailboxId}`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -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}?message_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}?message_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}?message_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;
|
||||
};
|
||||
@@ -30,7 +30,7 @@ export interface TagCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const get_top_tags = async () => {
|
||||
export const get_tags = async () => {
|
||||
const response = await axiosInstance.get<TagCount[]>("/api/v1/all-tags");
|
||||
return response.data;
|
||||
}
|
||||
@@ -41,3 +41,9 @@ export const update_tags = async (data: Record<string, any>) => {
|
||||
};
|
||||
|
||||
|
||||
export const get_contacts = async () => {
|
||||
const response = await axiosInstance.get<string[]>("/api/v1/all-contacts");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,33 +14,33 @@ export interface UserRole {
|
||||
}
|
||||
|
||||
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' },
|
||||
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' },
|
||||
// 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' },
|
||||
]
|
||||
// 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 {
|
||||
@@ -85,10 +85,16 @@ export interface User {
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +180,11 @@ export const list_minimal_users = async () => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const list_account_roles = async () => {
|
||||
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-account-roles");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const remove_user = async (id: number) => {
|
||||
const response = await axiosInstance.delete(`/api/v1/users/${id}`);
|
||||
return response.data;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
@@ -79,6 +80,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{confirmText ?? t('dialogs.continue')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function DatePicker({
|
||||
{selected ? (
|
||||
format(selected, 'PPP', { locale: dateLocale })
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
<span className='text-xs'>{placeholder}</span>
|
||||
)}
|
||||
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { NavGroup } from '@/components/layout/nav-group'
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { useSidebarData } from './data/sidebar-data'
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { open } = useSidebar();
|
||||
@@ -36,22 +37,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarHeader>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
asChild
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<div className='flex aspect-square size-16 items-center justify-center rounded-lg text-sidebar-primary-foreground'>
|
||||
<img
|
||||
className={open ? "relative ml-[12px] mr-[12px]" : "mr-[30px]"}
|
||||
src={Logo}
|
||||
width={open ? 60 : 40}
|
||||
height={open ? 60 : 40}
|
||||
alt='Logo'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid flex-1 text-left text-lg leading-tight'>
|
||||
<span className='truncate font-semibold'>
|
||||
Bichon
|
||||
</span>
|
||||
</div>
|
||||
<Link to="/">
|
||||
<div className='flex aspect-square size-16 items-center justify-center rounded-lg text-sidebar-primary-foreground'>
|
||||
<img
|
||||
className={open ? "relative ml-[12px] mr-[12px]" : "mr-[30px]"}
|
||||
src={Logo}
|
||||
width={open ? 60 : 40}
|
||||
height={open ? 60 : 40}
|
||||
alt='Logo'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid flex-1 text-left text-lg leading-tight'>
|
||||
<span className='truncate font-semibold'>
|
||||
Bichon
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
|
||||
@@ -20,8 +20,11 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,14 +33,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { showNumbers } from '@/lib/utils'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface PaginationProps {
|
||||
totalItems: number
|
||||
pageIndex: number,
|
||||
pageSize: number,
|
||||
hasNextPage: () => boolean,
|
||||
setPageIndex: (pageIndex: number) => void,
|
||||
setPageSize: (pageSize: number) => void,
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
hasNextPage: () => boolean
|
||||
setPageIndex: (pageIndex: number) => void
|
||||
setPageSize: (pageSize: number) => void
|
||||
}
|
||||
|
||||
export function EnvelopeListPagination({
|
||||
@@ -49,8 +54,13 @@ export function EnvelopeListPagination({
|
||||
setPageSize,
|
||||
}: PaginationProps) {
|
||||
const { t } = useTranslation()
|
||||
const [pageInput, setPageInput] = useState(pageIndex + 1)
|
||||
const pageCount = Math.ceil(totalItems / pageSize)
|
||||
|
||||
useEffect(() => {
|
||||
setPageInput(pageIndex + 1)
|
||||
}, [pageIndex])
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
const newPageSize = Number(value)
|
||||
setPageSize(newPageSize)
|
||||
@@ -66,6 +76,9 @@ export function EnvelopeListPagination({
|
||||
setPageIndex(newPageIndex)
|
||||
}
|
||||
|
||||
const currentPage = pageIndex + 1
|
||||
const pageNumbers = showNumbers(currentPage, pageCount)
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between space-x-2 overflow-auto px-2'>
|
||||
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
@@ -82,7 +95,7 @@ export function EnvelopeListPagination({
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50, 100].map((size) => (
|
||||
{[10, 20, 30, 40, 50, 100, 200].map((size) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
@@ -90,10 +103,30 @@ export function EnvelopeListPagination({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center justify-center text-sm font-medium'>
|
||||
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
|
||||
<div className='hidden items-center justify-center text-sm font-medium sm:flex'>
|
||||
{t("table.page")}
|
||||
<Input
|
||||
type="number"
|
||||
value={pageInput}
|
||||
onBlur={() => {
|
||||
if (Number.isNaN(pageInput)) return
|
||||
if (pageInput > 0) setPageIndex(pageInput - 1)
|
||||
else setPageIndex(0)
|
||||
}}
|
||||
onChange={(e) => setPageInput(Number(e.target.value))}
|
||||
className='mx-2 h-8 w-20'
|
||||
/>
|
||||
{t("table.of")} {pageCount}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => setPageIndex(0)}
|
||||
disabled={pageIndex === 0}
|
||||
>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-8 p-0'
|
||||
@@ -103,6 +136,22 @@ export function EnvelopeListPagination({
|
||||
<span className='sr-only'>{t("table.prevPage")}</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||
{pageNumber === '...' ? (
|
||||
<span className='px-1 text-sm text-muted-foreground'>...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className='h-8 min-w-8 px-2'
|
||||
onClick={() => setPageIndex((pageNumber as number) - 1)}
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-8 p-0'
|
||||
@@ -112,8 +161,16 @@ export function EnvelopeListPagination({
|
||||
<span className='sr-only'>{t("table.nextPage")}</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => setPageIndex(pageCount - 1)}
|
||||
disabled={!hasNextPage()}
|
||||
>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { resetToken } from '@/stores/authStore'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface SignOutDialogProps {
|
||||
open: boolean
|
||||
@@ -10,7 +11,7 @@ interface SignOutDialogProps {
|
||||
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const { t } = useTranslation()
|
||||
const handleSignOut = () => {
|
||||
resetToken()
|
||||
const currentPath = location.href
|
||||
@@ -25,12 +26,15 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title='Sign out'
|
||||
desc='Are you sure you want to sign out? You will need to sign in again to access your account.'
|
||||
confirmText='Sign out'
|
||||
title={t('sign_out.title', 'Sign out')}
|
||||
desc={t(
|
||||
'sign_out.desc',
|
||||
'Are you sure you want to sign out? You will need to sign in again to access your account.'
|
||||
)}
|
||||
confirmText={t('sign_out.confirm', 'Sign out')}
|
||||
destructive
|
||||
handleConfirm={handleSignOut}
|
||||
className='sm:max-w-sm'
|
||||
className="sm:max-w-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
interface ScrollAreaProps
|
||||
extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
orientation?: 'horizontal' | 'vertical' | 'both'
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
@@ -24,7 +24,12 @@ const ScrollArea = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar orientation={orientation} />
|
||||
{orientation === "both" ? (
|
||||
<>
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</>
|
||||
) : <ScrollBar orientation={orientation} />}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
@@ -40,9 +45,9 @@ const ScrollBar = React.forwardRef<
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,13 +5,13 @@ const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className='relative w-full overflow-auto'>
|
||||
// <div className='relative w-full overflow-auto'>
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
// </div>
|
||||
))
|
||||
Table.displayName = 'Table'
|
||||
|
||||
@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
<thead ref={ref} className={cn('[&_th]:sticky [&_th]:top-0 [&_th]:bg-background [&_th]:z-20', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
|
||||
61
web/src/components/ui/toggle-group.tsx
Normal file
61
web/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
43
web/src/components/ui/toggle.tsx
Normal file
43
web/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -224,6 +224,7 @@ interface VirtualizedSelectProps {
|
||||
defaultValue?: string | string[];
|
||||
noItemsComponent?: React.ReactNode;
|
||||
multiple?: boolean;
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
export function VirtualizedSelect({
|
||||
@@ -232,6 +233,7 @@ export function VirtualizedSelect({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
size = 'default',
|
||||
isLoading,
|
||||
disabled = false,
|
||||
placeholder = 'Search items...',
|
||||
@@ -273,7 +275,7 @@ export function VirtualizedSelect({
|
||||
.filter(Boolean);
|
||||
|
||||
if (selectedLabels.length === 0) return placeholder;
|
||||
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
|
||||
return selectedLabels.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -281,13 +283,14 @@ export function VirtualizedSelect({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size={size}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('justify-between', className)}
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<span className='truncate'>{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -20,7 +20,7 @@ import React from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -52,9 +52,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useRoles } from '@/hooks/use-roles'
|
||||
import { useMinimalUsers } from '@/hooks/use-minimal-users'
|
||||
import { access_assign, AccountModel } from '@/api/account/api'
|
||||
import { list_account_roles, list_minimal_users, MinimalUser, UserRole } from '@/api/users/api'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccountModel
|
||||
@@ -71,12 +70,23 @@ export function AccountAccessAssignmentDialog({
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { accountRoles, isLoading: isLoadingRoles } = useRoles()
|
||||
const { users, isLoading: isLoadingUsers } = useMinimalUsers()
|
||||
const { data: roles, isLoading: isLoadingRoles } = useQuery<UserRole[]>({
|
||||
queryKey: ['account-role-list'],
|
||||
queryFn: list_account_roles,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
|
||||
const { data: users, isLoading: isLoadingUsers } = useQuery<MinimalUser[]>({
|
||||
queryKey: ['minimal-user-list'],
|
||||
queryFn: list_minimal_users,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const [keyword, setKeyword] = React.useState('')
|
||||
|
||||
// 1. 定义校验 Schema (集成国际化错误提示)
|
||||
const assignmentSchema = z.object({
|
||||
account_ids: z.array(z.number()),
|
||||
user_ids: z.array(z.number()).min(1, {
|
||||
@@ -101,7 +111,7 @@ export function AccountAccessAssignmentDialog({
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
if (!keyword.trim()) return users
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
return users.filter(
|
||||
return users!.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(lowerKeyword) ||
|
||||
user.email.toLowerCase().includes(lowerKeyword)
|
||||
@@ -170,7 +180,7 @@ export function AccountAccessAssignmentDialog({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{accountRoles.map((role) => (
|
||||
{roles && roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
@@ -206,12 +216,12 @@ export function AccountAccessAssignmentDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-1">
|
||||
{filteredUsers.length === 0 ? (
|
||||
{filteredUsers && (filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
{t('accounts.access_control.user_empty')}
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
filteredUsers!.map((user) => (
|
||||
<FormField
|
||||
key={user.id}
|
||||
control={form.control}
|
||||
@@ -242,7 +252,7 @@ export function AccountAccessAssignmentDialog({
|
||||
)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -128,6 +128,7 @@ const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.nullable()
|
||||
.optional(),
|
||||
sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
|
||||
sync_batch_size: z
|
||||
@@ -221,7 +222,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
const accountSchema = getAccountSchema(isEdit, t);
|
||||
const form = useForm<Account>({
|
||||
mode: "all",
|
||||
mode: "onChange",
|
||||
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
|
||||
resolver: zodResolver(accountSchema),
|
||||
});
|
||||
@@ -290,7 +291,13 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
sync_batch_size: data.sync_batch_size,
|
||||
};
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(commonData);
|
||||
const isAllMode = !data.date_since && !data.date_before;
|
||||
const clear_folder_limit = !data.folder_limit;
|
||||
updateMutation.mutate({
|
||||
...commonData,
|
||||
...(isAllMode ? { clear_date_range: true } : {}),
|
||||
...(clear_folder_limit ? { clear_folder_limit: true } : {})
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate({ ...commonData, account_type: "IMAP" });
|
||||
}
|
||||
@@ -351,7 +358,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
onOpenChange(state);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[90vh]">
|
||||
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[50rem]">
|
||||
<div className="p-6 pb-2 flex-shrink-0">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
|
||||
|
||||
@@ -116,11 +116,11 @@ export function useColumns(): ColumnDef<AccountModel>[] {
|
||||
cell: ({ row }) => {
|
||||
const { created_user_name, created_user_email } = row.original;
|
||||
return (
|
||||
<div className="flex flex-col py-1 text-center">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
<div className="flex flex-col items-center leading-[1.1]">
|
||||
<span className="text-[13px] font-medium text-foreground leading-none">
|
||||
{created_user_name}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground font-mono">
|
||||
<span className="text-[11px] text-muted-foreground font-mono leading-none">
|
||||
{created_user_email}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<NoSyncAccount>({
|
||||
mode: "all",
|
||||
mode: "onChange",
|
||||
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
|
||||
resolver: zodResolver(accountSchema(t)),
|
||||
});
|
||||
@@ -164,7 +164,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
{t('accounts.clickSaveWhenDone')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
|
||||
<ScrollArea className='h-[13rem] w-full pr-4 -mr-4 py-1'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='nosync-account-form'
|
||||
|
||||
@@ -79,13 +79,13 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-7xl sm:rounded-xl p-0 overflow-hidden">
|
||||
<DialogContent className="w-full max-w-7xl sm:rounded-xl p-0 overflow-hidden max-h-[90vh]">
|
||||
<DialogHeader className="text-left space-y-2 px-4 sm:px-6 pt-4 sm:pt-6">
|
||||
<DialogTitle className="flex flex-wrap items-center gap-2 text-base sm:text-lg">
|
||||
<span className="text-blue-500 font-medium truncate">{currentRow.email}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[85vh] px-4 sm:px-6 pb-6">
|
||||
<ScrollArea className="max-h-[calc(90vh-8rem)] px-4 sm:px-6 pb-6">
|
||||
{isLoading && (
|
||||
<div className="space-y-4 py-6">
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
|
||||
@@ -178,7 +178,6 @@ export default function Step3() {
|
||||
onSelect={(date) => field.onChange(date?.toLocaleDateString('en-CA'))}
|
||||
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
|
||||
locale={dateLocale}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -244,8 +243,11 @@ export default function Step3() {
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('accounts.folderLimitPlaceholder')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(value === '' ? null : Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import i18n from '@/i18n'
|
||||
import { Loader2, LogIn } from 'lucide-react'
|
||||
import { login } from '@/api/users/api'
|
||||
import { useTheme } from '@/context/theme-context'
|
||||
|
||||
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
@@ -59,6 +60,7 @@ const getFormSchema = (t: (key: string, options?: Record<string, any>) => string
|
||||
|
||||
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { setTheme } = useTheme();
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -86,6 +88,15 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
setToken(result);
|
||||
|
||||
if (result.theme) {
|
||||
setTheme(result.theme);
|
||||
}
|
||||
|
||||
if (result.language) {
|
||||
i18n.changeLanguage(result.language);
|
||||
}
|
||||
|
||||
navigate({ to: redirect });
|
||||
} else {
|
||||
toast({
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function MailArchiveDashboard() {
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<Main higher>
|
||||
<div className="flex-1 space-y-6 p-6 md:p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -304,7 +304,6 @@ export default function MailArchiveDashboard() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Attachments */}
|
||||
<TabsContent value="attachment" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -459,7 +458,6 @@ export default function MailArchiveDashboard() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Footer / Copyright - New Addition */}
|
||||
<div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground">
|
||||
© 2025 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
|
||||
</div>
|
||||
|
||||
105
web/src/features/mailbox/components/delete-mailbox-dialog.tsx
Normal file
105
web/src/features/mailbox/components/delete-mailbox-dialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// 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 { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMailboxContext } from '../context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { delete_mailbox } from '@/api/mailbox/api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useMailboxContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ accountId, mailboxId }: { accountId: number; mailboxId: string }) =>
|
||||
delete_mailbox(accountId, mailboxId),
|
||||
retry: false,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-mailboxes', `${selectedAccountId}`] });
|
||||
onOpenChange(false);
|
||||
setDeleteMailboxId(undefined);
|
||||
toast({
|
||||
title: t('mailbox.deleteMailboxDialog.successTitle'),
|
||||
description: t('mailbox.deleteMailboxDialog.successDesc'),
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('mailbox.deleteMailboxDialog.errorTitle'),
|
||||
description: error.message || "Delete failed",
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedAccountId && deleteMailboxId) {
|
||||
deleteMutation.mutate({
|
||||
accountId: selectedAccountId,
|
||||
mailboxId: deleteMailboxId
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
onOpenChange(isOpen);
|
||||
if (!isOpen) setDeleteMailboxId(undefined);
|
||||
}}
|
||||
handleConfirm={handleDelete}
|
||||
className="max-w-xl"
|
||||
isLoading={isLoading}
|
||||
title={
|
||||
<span className="text-destructive">
|
||||
<IconAlertTriangle
|
||||
className="mr-1 inline-block stroke-destructive"
|
||||
size={18}
|
||||
/>{' '}
|
||||
{t('mailbox.deleteMailboxDialog.title')}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className="space-y-4">
|
||||
<p className="mb-2">
|
||||
{t('mailbox.deleteMailboxDialog.desc')}
|
||||
</p>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('mailbox.deleteMailboxDialog.warningTitle')}</AlertTitle>
|
||||
<AlertDescription>{t('mailbox.deleteMailboxDialog.warningDesc')}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText={t('mailbox.deleteMailboxDialog.confirm')}
|
||||
destructive
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { MailIcon, Paperclip, Trash2 } from "lucide-react"
|
||||
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { useMailboxContext } from "../context"
|
||||
@@ -28,6 +28,8 @@ import { MailBulkActions } from "./bulk-actions"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
@@ -109,7 +111,7 @@ export function MailList({
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selected.size > 0
|
||||
? `${selected.size} ${t('common.selected')}`
|
||||
? `${t('search.bulkActions.selected', { count: selected.size })}`
|
||||
: t('common.selectAll')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -142,7 +144,6 @@ export function MailList({
|
||||
/>
|
||||
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
|
||||
{/* LEFT AREA: From + Subject + Tags */}
|
||||
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-1">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.from}</p>
|
||||
@@ -154,7 +155,6 @@ export function MailList({
|
||||
{item.subject}
|
||||
</h3>
|
||||
|
||||
{/* TAGS BELOW SUBJECT */}
|
||||
<div className="flex flex-wrap gap-1 mt-0.25">
|
||||
{item.tags?.map((tag, i) => (
|
||||
<Badge
|
||||
@@ -167,7 +167,6 @@ export function MailList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT AREA – actions & meta */}
|
||||
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
{hasAttachments && (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -182,15 +181,44 @@ export function MailList({
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(item)
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-all"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(new Set([item.id]));
|
||||
setOpen("restore");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="ml-2 h-3.5 w-3.5" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore } from 'lucide-react';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileAudio, FileVideo, FileSpreadsheet, FileArchive, FileCode, FileIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -83,6 +83,35 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const getFileConfig = (mimeType: string) => {
|
||||
const type = mimeType.toLowerCase();
|
||||
if (type.includes('pdf')) {
|
||||
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
|
||||
}
|
||||
if (type.includes('image/')) {
|
||||
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
|
||||
}
|
||||
if (type.includes('audio/')) {
|
||||
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
|
||||
}
|
||||
|
||||
if (type.includes('video/')) {
|
||||
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
|
||||
}
|
||||
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
|
||||
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
|
||||
}
|
||||
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
|
||||
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
|
||||
}
|
||||
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
|
||||
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
|
||||
}
|
||||
|
||||
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
|
||||
};
|
||||
|
||||
export function MailMessageView({
|
||||
envelope,
|
||||
showActions = true,
|
||||
@@ -245,11 +274,24 @@ export function MailMessageView({
|
||||
const nonInline = attachments.filter((a) => !a.inline);
|
||||
return nonInline.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{nonInline.map((attachment, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<span className="truncate text-xs">{attachment.filename}</span>
|
||||
<span className="text-xs px-2 py-1 rounded">[{attachment.file_type}]</span>
|
||||
{nonInline.map((attachment, i) => {
|
||||
const { icon, color } = getFileConfig(attachment.file_type);
|
||||
return <div key={i} className="flex items-center">
|
||||
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
|
||||
<div className={`flex-shrink-0 ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
|
||||
<span
|
||||
className="truncate text-xs font-medium text-foreground/90"
|
||||
title={attachment.filename}
|
||||
>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
|
||||
{attachment.file_type.split('/').pop()?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
<span className="text-gray-500 text-xs shrink-0">
|
||||
@@ -268,7 +310,7 @@ export function MailMessageView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs italic">
|
||||
|
||||
@@ -49,7 +49,12 @@ import { styled } from "@mui/material/styles"
|
||||
import { animated, useSpring } from "@react-spring/web"
|
||||
import { TransitionProps } from "@mui/material/transitions"
|
||||
import Collapse from "@mui/material/Collapse"
|
||||
import { FolderIcon } from "lucide-react"
|
||||
import { FolderIcon, MoreVertical, Trash2 } from "lucide-react"
|
||||
import { RestoreMessageDialog } from "./restore-message-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { MailBoxDeleteDialog } from "./delete-mailbox-dialog"
|
||||
|
||||
|
||||
interface MailProps {
|
||||
@@ -78,15 +83,14 @@ const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessages
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
interface CustomLabelProps {
|
||||
exists?: number;
|
||||
attributes?: { attr: string; extension: string | null }[],
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
icon?: React.ElementType;
|
||||
expandable?: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function CustomLabel({
|
||||
@@ -94,8 +98,11 @@ function CustomLabel({
|
||||
exists,
|
||||
attributes,
|
||||
children,
|
||||
id,
|
||||
onDelete,
|
||||
...other
|
||||
}: CustomLabelProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<TreeItemLabel
|
||||
{...other}
|
||||
@@ -104,31 +111,43 @@ function CustomLabel({
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FolderIcon className="mr-2"/>
|
||||
<FolderIcon className="mr-2" />
|
||||
<span className="font-medium text-sm text-inherit">
|
||||
{children}
|
||||
</span>
|
||||
{/* <div className="flex gap-2 ml-auto mr-3 opacity-70 text-xs">
|
||||
{attributes?.map((attr) => {
|
||||
const text =
|
||||
attr.attr === 'Extension'
|
||||
? attr.extension
|
||||
: attr.attr;
|
||||
|
||||
return (
|
||||
<span key={attr.attr} className="text-inherit">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<div className="ml-auto flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-20">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDelete(id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t('common.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{exists !== undefined && (
|
||||
<span
|
||||
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
|
||||
>
|
||||
{exists}
|
||||
</span>
|
||||
)} */}
|
||||
</TreeItemLabel>
|
||||
);
|
||||
}
|
||||
@@ -171,6 +190,8 @@ export function Mail({
|
||||
const [pageSize, setPageSize] = React.useState(30);
|
||||
const [deleteIds, setDeleteIds] = React.useState<Set<number>>(() => new Set());
|
||||
const [selected, setSelected] = React.useState<Set<number>>(() => new Set());
|
||||
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
|
||||
@@ -213,15 +234,29 @@ export function Mail({
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
const handleItemSelectionToggle = (
|
||||
// const handleItemSelectionToggle = (
|
||||
// _event: React.SyntheticEvent | null,
|
||||
// itemId: string,
|
||||
// isSelected: boolean,
|
||||
// ) => {
|
||||
// if (isSelected) {
|
||||
// setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
|
||||
// setPage(0);
|
||||
// }
|
||||
// };
|
||||
|
||||
const handleItemClick = (
|
||||
_event: React.SyntheticEvent | null,
|
||||
itemId: string,
|
||||
isSelected: boolean,
|
||||
itemId: string
|
||||
) => {
|
||||
if (isSelected) {
|
||||
setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
|
||||
setPage(0);
|
||||
}
|
||||
//console.log(itemId)
|
||||
setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setDeleteMailboxId(id);
|
||||
setOpen('delete');
|
||||
};
|
||||
|
||||
const CustomTreeItem = React.useMemo(() => {
|
||||
@@ -256,6 +291,8 @@ export function Mail({
|
||||
<CustomLabel
|
||||
{...getLabelProps({
|
||||
exists: item.exists,
|
||||
id: item.id,
|
||||
onDelete: handleDeleteClick,
|
||||
attributes: item.attributes,
|
||||
expandable: status.expandable && status.expanded,
|
||||
})}
|
||||
@@ -269,11 +306,22 @@ export function Mail({
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<MailboxProvider value={{ open, setOpen, currentMailbox: selectedMailbox, selectedAccountId, setCurrentMailbox: setSelectedMailbox, currentEnvelope: selectedEvelope, setCurrentEnvelope: setSelectedEvelope, deleteIds, setDeleteIds, selected, setSelected }}>
|
||||
<MailboxProvider value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentMailbox: selectedMailbox,
|
||||
selectedAccountId,
|
||||
setCurrentMailbox: setSelectedMailbox,
|
||||
currentEnvelope: selectedEvelope,
|
||||
setCurrentEnvelope: setSelectedEvelope,
|
||||
deleteIds,
|
||||
setDeleteIds,
|
||||
selected,
|
||||
setSelected,
|
||||
deleteMailboxId,
|
||||
setDeleteMailboxId
|
||||
}}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
@@ -301,7 +349,7 @@ export function Mail({
|
||||
)}
|
||||
>
|
||||
<Separator className="mb-2" />
|
||||
<ScrollArea className='h-[50rem] w-full pr-4 -mr-4 py-1'>
|
||||
<ScrollArea className='h-[calc(100vh-8rem)] w-full pr-4 -mr-4 py-1'>
|
||||
<div>
|
||||
<AccountSwitcher onAccountSelect={(accountId) => {
|
||||
localStorage.setItem('mailbox:selectedAccountId', `${accountId}`);
|
||||
@@ -333,7 +381,8 @@ export function Mail({
|
||||
<RichTreeView
|
||||
//checkboxSelection
|
||||
items={tree}
|
||||
onItemSelectionToggle={handleItemSelectionToggle}
|
||||
expansionTrigger="iconContainer"
|
||||
onItemClick={handleItemClick}
|
||||
slots={{ item: CustomTreeItem }}
|
||||
/>
|
||||
)}
|
||||
@@ -350,12 +399,12 @@ export function Mail({
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="mt-2">
|
||||
<ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
|
||||
<ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1'>
|
||||
<MailList
|
||||
isLoading={isMessagesLoading}
|
||||
items={(envelopes?.items ?? []).sort((a, b) => {
|
||||
const dateA = a.internal_date;
|
||||
const dateB = b.internal_date;
|
||||
const dateA = a.date;
|
||||
const dateB = b.date;
|
||||
return dateB - dateA;
|
||||
})}
|
||||
/>
|
||||
@@ -402,6 +451,18 @@ export function Mail({
|
||||
open={open === 'move-to-trash'}
|
||||
onOpenChange={() => setOpen('move-to-trash')}
|
||||
/>
|
||||
|
||||
<RestoreMessageDialog
|
||||
key='envelope-restore'
|
||||
open={open === 'restore'}
|
||||
onOpenChange={() => setOpen('restore')}
|
||||
/>
|
||||
<MailBoxDeleteDialog
|
||||
key='mailbox-delete'
|
||||
open={open === 'delete'}
|
||||
onOpenChange={() => setOpen('delete')}
|
||||
/>
|
||||
|
||||
</MailboxProvider >
|
||||
)
|
||||
}
|
||||
106
web/src/features/mailbox/components/restore-message-dialog.tsx
Normal file
106
web/src/features/mailbox/components/restore-message-dialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// 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 { restore_message } from '@/api/mailbox/envelope/api'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMailboxContext } from '../context'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
|
||||
interface RestoreMessageDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function RestoreMessageDialog({
|
||||
open,
|
||||
onOpenChange
|
||||
}: RestoreMessageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { selectedAccountId, selected, setSelected } = useMailboxContext();
|
||||
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (messageIds: number[]) =>
|
||||
restore_message(selectedAccountId!, messageIds),
|
||||
onSuccess: handleRestoreSuccess,
|
||||
onError: handleRestoreError,
|
||||
});
|
||||
|
||||
function handleRestoreSuccess() {
|
||||
toast({
|
||||
title: t('restore_message.success', 'Messages restored'),
|
||||
description: t(
|
||||
'restore_message.successDesc',
|
||||
'The selected messages have been restored to the IMAP server.'
|
||||
),
|
||||
action: (
|
||||
<ToastAction altText={t('common.close')}>
|
||||
{t('common.close')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
setSelected(new Set());
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleRestoreError(error: AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
t('restore_message.failed', 'Failed to restore messages');
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t(
|
||||
'restore_message.failedTitle',
|
||||
'Restore failed'
|
||||
),
|
||||
description: errorMessage,
|
||||
action: (
|
||||
<ToastAction altText={t('common.tryAgain')}>
|
||||
{t('common.tryAgain')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('restore_message.title', 'Restore messages')}
|
||||
desc={t(
|
||||
'restore_message.desc',
|
||||
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
|
||||
)}
|
||||
confirmText={t('restore_message.confirm', 'Restore')}
|
||||
handleConfirm={() => restoreMutation.mutate(Array.from(selected))}
|
||||
className="sm:max-w-sm"
|
||||
isLoading={restoreMutation.isPending}
|
||||
disabled={restoreMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -116,11 +116,11 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
|
||||
)}
|
||||
|
||||
{allMessages
|
||||
.sort((a, b) => a.internal_date - b.internal_date)
|
||||
.sort((a, b) => a.date - b.date)
|
||||
.map((msg) => {
|
||||
const isExpanded = expandedIds.has(msg.id);
|
||||
const preview = msg.text?.slice(0, 120) + (msg.text?.length > 120 ? '...' : '');
|
||||
const date = new Date(msg.internal_date);
|
||||
const date = new Date(msg.date);
|
||||
const formattedDate = isNaN(date.getTime())
|
||||
? t('mailbox.thread.invalidDate')
|
||||
: format(date, 'yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
@@ -21,7 +21,7 @@ import React from 'react'
|
||||
import { MailboxData } from '@/api/mailbox/api'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
|
||||
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters'
|
||||
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore' | 'delete'
|
||||
|
||||
interface MailboxContextType {
|
||||
open: MailboxDialogType | null
|
||||
@@ -30,6 +30,8 @@ interface MailboxContextType {
|
||||
currentMailbox: MailboxData | undefined
|
||||
currentEnvelope: EmailEnvelope | undefined
|
||||
setCurrentMailbox: React.Dispatch<React.SetStateAction<MailboxData | undefined>>
|
||||
deleteMailboxId: string | undefined,
|
||||
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
setCurrentEnvelope: React.Dispatch<React.SetStateAction<EmailEnvelope | undefined>>
|
||||
deleteIds: Set<number>
|
||||
setDeleteIds: React.Dispatch<React.SetStateAction<Set<number>>>
|
||||
|
||||
@@ -32,7 +32,6 @@ export default function Mailboxes() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ===== Top Heading ===== */}
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<Mail
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user