82 Commits
0.2.0 ... 0.3.6

Author SHA1 Message Date
rustmailer
1fde24b3db bump to 0.3.6 2026-01-24 22:11:02 +08:00
rustmailer
e29e8d76b2 fix: dashboard fails with error 500 #80 2026-01-24 20:42:12 +08:00
rustmailer
62532e2740 Merge branch 'main' of https://github.com/rustmailer/bichon 2026-01-24 20:21:04 +08:00
rustmailer
0fc99aa172 chore: docker: bundle bichonctl and bichon-admin into Docker image #136 2026-01-24 20:20:40 +08:00
rustmailer
852ae2b782 chore: docker: bundle bichonctl and bichon-admin into Docker image 2026-01-24 20:20:18 +08:00
rustmailer
0fb79c9a8b fix: PUID is taken in default ubuntu base image #132 2026-01-23 20:02:07 +08:00
rustmailer
0dad25c993 Update README.md 2026-01-23 13:25:09 +08:00
rustmailer
ee7ea3872f bump to 0.3.5 2026-01-23 13:09:46 +08:00
rustmailer
a440479946 feat: set frontend request timeout to 1 minute 2026-01-23 13:06:46 +08:00
rustmailer
ecb81ac344 feat: limit concurrent mailbox downloads to 5 per account 2026-01-23 13:06:28 +08:00
rustmailer
451b5338f1 fix: Group add issue #131 2026-01-23 12:21:14 +08:00
rustmailer
ddd93ff4ab Update README.md 2026-01-22 16:49:28 +08:00
rustmailer
0bfe379310 update 2026-01-22 15:52:52 +08:00
rustmailer
e47a81d510 Update release.yml 2026-01-22 15:15:19 +08:00
rustmailer
50bdf691dc Update release.yml 2026-01-22 13:42:20 +08:00
rustmailer
11be1e4758 Merge pull request #129 from ItsVRK/feature/125-permissions_nfs_volumes
#125 fix: allow setting of PUID and PGID to prevent permission issues when using NFS mounts or shared volumes
2026-01-22 13:20:54 +08:00
rustmailer
694e5ecbec feat: reset the login password #126 2026-01-22 13:19:21 +08:00
rustmailer
579801ef5c Update mail.tsx 2026-01-22 10:25:52 +08:00
rustmailer
feedb91225 fix: Inbox closed when it is already openend #122 2026-01-22 10:25:13 +08:00
rustmailer
57a6e3c62e fix: Account detail modal doesn't fit in viewport #123 2026-01-22 09:27:28 +08:00
itsvrk
f7fe1f3072 fix: allow setting of PUID and PGID to prevent permission issues when using NFS mounts or shared volumes 2026-01-22 12:12:41 +11:00
rustmailer
8e25b0da14 bump version to 0.3.3 2026-01-21 01:16:58 +08:00
rustmailer
e1f471b8f4 chore: support bulk restore emails 2026-01-21 01:09:38 +08:00
rustmailer
fcd19b1c9f Fix: storage dir creation logic and permissions issues ( #120, #121) 2026-01-21 00:10:07 +08:00
rustmailer
df1a6f8c5b Update README.md 2026-01-20 10:06:13 +08:00
rustmailer
7d150c2982 bump to 0.3.2 2026-01-20 01:25:53 +08:00
rustmailer
9b49005522 fix: "unknown" sender when importing PST #117 2026-01-20 01:22:06 +08:00
rustmailer
1f4e9f7b06 Update nosync-dialog.tsx 2026-01-20 01:21:42 +08:00
rustmailer
d0e4cac229 i18n 2026-01-20 00:53:09 +08:00
rustmailer
30a8d856c1 update 2026-01-19 22:53:11 +08:00
rustmailer
7240be8c31 feat: Separate Docker config file location and email data storage location #81 2026-01-19 22:53:03 +08:00
rustmailer
0d20a9676a feat(search-ui): optimize search UI 2026-01-19 01:52:07 +08:00
rustmailer
48f8092b5a Merge pull request #113 from ktdd/search-improvements-v1
Search improvements
2026-01-14 14:32:38 +08:00
rustmailer
a64351d409 Merge pull request #112 from ktdd/search-table-v1
Replaced email listing with a table with adjustable columns + other changes
2026-01-14 14:32:24 +08:00
rustmailer
a6dd1d19ff Update README.md 2026-01-14 14:01:05 +08:00
rustmailer
f82b20e2fd chore: Set minimum username length to 3 #106 2026-01-14 01:11:01 +08:00
rustmailer
9841038acb chore: Adjust dark mode brightness and light mode saturation #109 2026-01-14 01:04:10 +08:00
rustmailer
97c3db3bd2 chore: default to binding 0.0.0.0 and support binding IPv6 addresses. 2026-01-14 00:27:31 +08:00
rustmailer
82fb2a02bc Merge pull request #110 from op3/feat/support-listening-on-ipv6
Support listening on IPv6 addresses
2026-01-13 23:59:02 +08:00
rustmailer
c61977ce5c Merge pull request #107 from metlos/no-cap-on-sync-interval
Remove the maximum from the sync_interval_min.
2026-01-13 23:51:14 +08:00
rustmailer
106a08fb7e bump verison to 0.3.1 2026-01-13 23:34:46 +08:00
rustmailer
3419506c2e Update README.md 2026-01-13 23:34:17 +08:00
rustmailer
3b040d0cd6 feat: add support for Outlook PST file import #105 2026-01-13 23:32:18 +08:00
rustmailer
5d3c319a67 fix: skip invalid MBOX files during import 2026-01-13 23:31:45 +08:00
ktdd
ad2a43aa35 Search improvements 2026-01-13 12:48:55 +02:00
ktdd
884ae64fc5 Formatting 2026-01-12 12:22:52 +02:00
ktdd
254de35f0e Replaced email listing with a table with adjustable columns. 2026-01-11 18:15:03 +02:00
Oliver Papst
bc3eba5bf7 feat: change default bind address to :: for dual‑stack support
The socket bound to :: accepts both IPv6 and IPv4 (mapped) connections,
so this change enables IPv6 connectivity in addition to the existing
IPv4 behaviour.
2026-01-10 22:36:31 +01:00
Oliver Papst
4dc99b4a84 feat: Add IPv6 support for bichon_bind_ip configuration
Also try to parse the bind_ip address as std::net::Ipv6Addr to accept
both IPv4 and IPv6 addresses. The TcpListener of poem utilizes
ToSocketAddrs trait, which also supports IPv6.
2026-01-10 22:25:31 +01:00
Lukas Krejci
8ce8b9692d Remove the maximum from the sync_interval_min. 2026-01-09 01:35:58 +01:00
rustmailer
3fb761064d Update README.md 2026-01-08 10:52:30 +08:00
rustmailer
54a0a71c44 fix: Missing permission 'user:manage' #102 2026-01-07 23:01:25 +08:00
rustmailer
b490923e17 refactor(search): search filtering and sorting 2026-01-07 16:40:45 +08:00
rustmailer
4ee44daf0d Merge pull request #103 from ktdd/presets-and-sort
Updated presets and added a 'sort by' feature.
2026-01-07 15:03:17 +08:00
ktdd
7edd7c2e35 Updated presets and added a 'sort by' feature. 2026-01-06 12:33:09 +02:00
rustmailer
0c46432150 fix: Inline attachments are not counted as attachments and are not shown when searching for emails with attachments. 2026-01-06 16:28:55 +08:00
rustmailer
d334a23ca7 chore(ui): add attachment file type icon 2026-01-06 16:27:05 +08:00
rustmailer
1768c1a590 fix: Large empty space at the bottom of the screen #98 2026-01-06 14:36:01 +08:00
rustmailer
147f5b4f55 Update README.md 2026-01-05 22:34:24 +08:00
rustmailer
48312dc83e Update README.md 2026-01-05 22:21:53 +08:00
rustmailer
55e97510c4 fix: Folder limit cannot be empty #97 2026-01-05 21:32:00 +08:00
rustmailer
0bf2003670 chore(release): package bichonctl together with bichon binaries 2026-01-05 18:38:34 +08:00
rustmailer
e56fe5ebea feat(mailbox): support mailbox cleanup #96 2026-01-05 18:27:40 +08:00
rustmailer
c69ada32ef feat(ui): add clickable logo to redirect to homepage #95 2026-01-05 14:31:47 +08:00
rustmailer
3f4b37be17 feat(cli): add interactive email import tool for EML, MBOX, and Thunderbird
- Implement `bichonctl` interactive CLI using `dialoguer`.
- Support recursive EML directory scanning with folder structure preservation.
- Support single MBOX file streaming import.
- Support Thunderbird profile import with automatic `.sbd` hierarchy detection.
- Add batch processing (Base64 encoding & batch API requests) for improved performance.
2026-01-05 14:31:04 +08:00
rustmailer
09375ee11c fix: #94 2026-01-01 20:28:23 +08:00
rustmailer
b2e43b0907 fix: skip default admin role validation when global_roles is None #93 2026-01-01 15:24:51 +08:00
rustmailer
44fc0e15de bump versions 2025-12-31 22:57:31 +08:00
rustmailer
ef891b20c3 fix(account): update sync range and handle all-mode reset 2025-12-31 22:57:20 +08:00
rustmailer
a6216c2ce6 feat: support restoring single message to IMAP #77 2025-12-31 22:56:06 +08:00
rustmailer
1c58b516dd update sign-out dialog 2025-12-31 02:44:43 +08:00
rustmailer
ae916574de Fix: modifying the admin user 2025-12-31 02:43:57 +08:00
rustmailer
14fb3368a3 udpate locales files 2025-12-31 02:40:58 +08:00
rustmailer
f49929dd67 Update message.rs 2025-12-30 22:41:51 +08:00
rustmailer
97143d55b8 Merge pull request #67 from mmaudet/feat/envelope-endpoint-and-api-improvements
feat(api): Add envelope endpoint and improve API documentation
2025-12-30 22:32:26 +08:00
rustmailer
e666f76d87 Merge branch 'main' into feat/envelope-endpoint-and-api-improvements 2025-12-30 22:32:11 +08:00
rustmailer
7fb6575f8d feat: use email 'Date' header for statistics and search filtering #87 2025-12-30 22:21:53 +08:00
rustmailer
fb0be8c5d1 bump version to 0.2.1 2025-12-30 15:11:57 +08:00
rustmailer
455e6b1a75 feat: support user appearance preferences with persisted theme and language #85 2025-12-30 15:09:41 +08:00
rustmailer
75cae51be9 feat(ui): Add quick page navigation to the email list pagination #85 2025-12-30 11:40:09 +08:00
rustmailer
62d956c7d6 feat: increase password max to 256, fix i18n, and force re-login #83
- Raise password maximum length from 32 to 256 characters
- fix profileSchema to accept `t` for proper internationalization
- Invalidate user's WebUI token on password change, requiring re-login
2025-12-30 11:05:07 +08:00
Michel-Marie MAUDET
70db81dc03 feat(api): Add envelope endpoint and improve API documentation
- Add GET /envelope/{account_id}/{message_id} endpoint to retrieve message envelope (metadata)
- Add get_envelope_by_id method to ENVELOPE_INDEX_MANAGER for querying single envelope
- Move message_id from query parameter to path parameter for clearer API paths:
  - /message-content/{account_id}/{message_id}
  - /download-message/{account_id}/{message_id}
  - /download-attachment/{account_id}/{message_id}
  - /envelope/{account_id}/{message_id}
- Fix API documentation descriptions to be more accurate:
  - search_messages: Now correctly describes search functionality
  - get_thread_messages: Mentions thread_id requirement
  - proxy endpoints: Fixed copy-paste errors from OAuth2 docs
- Update frontend API client to use new path-based URLs

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:12:16 +01:00
162 changed files with 10607 additions and 1541 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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 @@ Its 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 youd 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.
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Support%20the%20Project-FFDD00?logo=buy-me-a-coffee)](https://buymeacoffee.com/rustmailer)

2
config.toml Normal file
View File

@@ -0,0 +1,2 @@
base_url = "http://localhost:15630"
api_token = "lZHmfpH1CRr9XsRiOGd1RnOr"

View File

@@ -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
View 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

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1 @@
pub mod modules;

View File

@@ -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;

View File

@@ -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!(

View File

@@ -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;

View File

@@ -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()?;
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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 {

View 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(())
}

View File

@@ -0,0 +1 @@
pub mod meta;

169
src/modules/cli/auth.rs Normal file
View 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
View 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;
}
}
}

View 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
View 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());
}

View 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
View 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,
}

View 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
View 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(&current_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, &current_path, batch).await;
}
}
}
if !emls_batch.is_empty() {
send_to_bichon(client, config, account_id, &current_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,
&current_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
View 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
);
}
}
}

View 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());
}
}

View File

@@ -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 {

View File

@@ -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<()>;
}

View File

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

View File

@@ -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>();
}
}

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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) => {

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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;

View 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(())
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
use crate::modules::context::executors::MAIL_CONTEXT;
@@ -64,8 +63,15 @@ pub async fn convert_names_to_mailboxes(
for name in names.into_iter() {
// Convert the name into a MailBox structure
let mailbox_name = name.name().to_string();
let mut mailbox: MailBox = name.into();
tracing::debug!(
raw = &mailbox_name,
decoded = &mailbox.name,
"mailbox name comparison"
);
if contains_no_select(&mailbox.attributes) {
continue;
}

View File

@@ -16,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;

View File

@@ -0,0 +1,104 @@
use crate::{
encode_mailbox_name,
modules::{
account::migration::{AccountModel, AccountType},
context::executors::MAIL_CONTEXT,
error::{code::ErrorCode, BichonResult},
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
},
raise_error,
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
const MAX_RESTORE_COUNT: usize = 100;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RestoreMessagesRequest {
/// Message IDs to restore (max 100)
pub message_ids: Vec<u64>,
}
pub async fn restore_emails(account_id: u64, message_ids: Vec<u64>) -> BichonResult<()> {
if message_ids.len() > MAX_RESTORE_COUNT {
return Err(raise_error!(
format!(
"Too many messages to restore: {} (max {})",
message_ids.len(),
MAX_RESTORE_COUNT
),
ErrorCode::InvalidParameter
));
}
let account = AccountModel::check_account_exists(account_id).await?;
if !matches!(account.account_type, AccountType::IMAP) {
return Err(raise_error!(
"Account type is not IMAP".into(),
ErrorCode::Incompatible
));
}
let executor = MAIL_CONTEXT.imap(account.id).await?;
let mut failed = Vec::new();
for message_id in message_ids {
let result: BichonResult<()> = async {
let envelope = ENVELOPE_INDEX_MANAGER
.get_envelope_by_id(account_id, message_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Envelope not found: account_id={} message_id={}",
account_id, message_id
),
ErrorCode::ResourceNotFound
)
})?;
let eml = EML_INDEX_MANAGER
.get(account_id, message_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Email record not found: account_id={} id={}",
account_id, message_id
),
ErrorCode::ResourceNotFound
)
})?;
if let Some(mailbox_name) = envelope.mailbox_name {
executor
.append(encode_mailbox_name!(&mailbox_name), None, None, &eml)
.await?;
}
Ok(())
}
.await;
if let Err(err) = result {
failed.push(message_id);
tracing::warn!(
account_id = account_id,
message_id = message_id,
error = ?err,
"Failed to restore email"
);
}
}
if !failed.is_empty() {
tracing::info!(
account_id = account_id,
failed_count = failed.len(),
failed_message_ids = ?failed,
"Restore emails finished with partial failures"
);
}
Ok(())
}

View 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>,
}

View File

@@ -16,7 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod append;
pub mod contacts;
pub mod content;
pub mod delete;
pub mod list;

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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));
}

View File

@@ -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?)
}
}

View File

@@ -21,6 +21,8 @@ use crate::modules::common::auth::ClientContext;
use crate::modules::indexer::envelope::Envelope;
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
use crate::modules::message::append::restore_emails;
use crate::modules::message::append::RestoreMessagesRequest;
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
use crate::modules::message::delete::delete_messages_impl;
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
@@ -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?,
))
}
}

View File

@@ -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>> {

View File

@@ -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(),
))
}
}

View File

@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::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)

View File

@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use 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,

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::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,
}
}
}

View 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(())
}

View File

@@ -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,

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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,
}
}
}

View File

@@ -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();

View File

@@ -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.

View File

@@ -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>,
}

View File

@@ -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)?;

View File

@@ -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
View File

@@ -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)

View File

@@ -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
},

View File

@@ -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;
};

View File

@@ -48,7 +48,7 @@ export const get_thread_messages = async (accountId: number, thread_id: number,
}
export const download_attachment = async (accountId: number, id: number, attachmentFileName: string) => {
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}?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;
};

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -29,6 +29,7 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
interface ConfirmDialogProps {
open: boolean
@@ -79,6 +80,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
onClick={handleConfirm}
disabled={disabled || isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{confirmText ?? t('dialogs.continue')}
</Button>
</AlertDialogFooter>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)
}
}

View File

@@ -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"
/>
)
}

View File

@@ -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}

View File

@@ -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'

View 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 }

View 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 }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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" />

View File

@@ -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 />

View File

@@ -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({

View File

@@ -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>

View 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
/>
);
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 >
)
}

View 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}
/>
)
}

View File

@@ -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');

View File

@@ -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>>>

View File

@@ -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