85 Commits
0.0.1 ... 0.1.4

Author SHA1 Message Date
rustmailer
a1453f3cda Document environment variable quotes in container usage
Added notes on running Bichon in a container to README.
2025-12-07 00:42:10 +08:00
rustmailer
9e656b1f91 Bump version from 0.1.3 to 0.1.4 2025-12-07 00:22:41 +08:00
rustmailer
afac192280 Enhance CORS configuration section in README
Added detailed CORS configuration instructions for Bichon, including new behavior in v0.1.4 and examples for setting origins.
2025-12-07 00:22:07 +08:00
rustmailer
c00ffe8d11 feat(cors): remove default value for BICHON_CORS_ORIGINS and allow all origins when unset
- Changed behavior so that when BICHON_CORS_ORIGINS is not configured, CORS now allows any origin.
- Added debug logging to print incoming Origin and configured origins to help users diagnose CORS misconfiguration issues.
2025-12-07 00:03:21 +08:00
rustmailer
c90a2d552d Fix FAQ link in README.md 2025-12-06 22:04:35 +08:00
rustmailer
a4627a63cb Update README.md 2025-12-06 22:03:36 +08:00
rustmailer
684b9765fe Update README.md 2025-12-06 20:33:51 +08:00
rustmailer
b0ce7671a8 chore(ui): move name field to step 2 #30 2025-12-04 08:33:16 +08:00
rustmailer
4b13bf14c4 Fix(sync): missing initial sync start time after enabling a previously disabled account #32 2025-12-03 20:11:19 +08:00
rustmailer
0015192b4d bump version to 0.1.3 2025-12-03 09:27:17 +08:00
rustmailer
2f3acdd759 fix(ui, config): Ensure use_dangerous status is visible in ui 2025-12-03 09:26:34 +08:00
rustmailer
0fc83b693e fix(i18n, dashboard): Internationalize recent activity chart dates 2025-12-03 09:23:51 +08:00
rustmailer
9ba56ca5bb feat(i18n): Implement internationalization for date distance 2025-12-03 09:23:16 +08:00
rustmailer
8f7244ccb9 fix(account): Resolve name clearing issue and update field labels 2025-12-03 09:21:33 +08:00
rustmailer
454950374f feat(account): Prioritize dedicated name field for IMAP authentication username #28
Refactor account authentication logic to prioritize the `name` field over the `email` field during the IMAP connection phase.

- The `name` field now serves as the primary IMAP login credential and is no longer treated purely as an optional, descriptive field.
- If the `name` field is empty or unset, the system will fall back to using the full `email` address for authentication.
- This change supports IMAP providers that require a username different from the full email address (e.g., employee ID, specific account name).
2025-12-03 03:48:39 +08:00
rustmailer
3a2b42f5c3 fixZ: Send IMAP ID command after successful authentication to ensure compatibility with 163 mail servers #25 2025-11-30 17:40:10 +08:00
rustmailer
34ec3a7d5b bump versions 2025-11-30 05:53:39 +08:00
rustmailer
82397ab0cd fix(ui): fix sync folder selection jump issue; add auto-select children/parents and expand/collapse all folders button #21 2025-11-30 05:51:17 +08:00
rustmailer
1cfc12324f fix(ui): Handle IMAP connection failure gracefully during folder sync #23 2025-11-29 11:42:11 +08:00
rustmailer
dffdac3eb6 Update README.md 2025-11-29 01:24:00 +08:00
rustmailer
7d02e58e4e Update README.md 2025-11-29 00:35:16 +08:00
rustmailer
847cc6825a Update README.md 2025-11-29 00:34:22 +08:00
rustmailer
6ce1420714 feat(import): introduce /api/v1/import endpoint to support batch EML email import 2025-11-27 22:11:05 +08:00
rustmailer
9a72ce9154 update 2025-11-27 15:18:12 +08:00
rustmailer
9f713ef044 update 2025-11-27 15:12:31 +08:00
rustmailer
275cde180b update issue templates 2025-11-27 15:01:14 +08:00
rustmailer
c82fb4f301 Update issue templates 2025-11-27 14:49:37 +08:00
rustmailer
736270b08c feat(dashboard): Display system version and Git hash, link to release tag #19 2025-11-27 03:55:20 +08:00
rustmailer
855c9a1ffc update 2025-11-27 02:01:09 +08:00
rustmailer
b845eda58a update 2025-11-27 01:49:20 +08:00
rustmailer
6571966228 update 2025-11-27 01:36:42 +08:00
rustmailer
c727460251 update 2025-11-27 01:27:16 +08:00
rustmailer
7c7e353114 feat: add option to trust any TLS certificate for IMAP connections 2025-11-27 00:57:43 +08:00
rustmailer
78b33f8994 Update README.md 2025-11-26 22:28:54 +08:00
rustmailer
324051bdf0 Revise README to enhance project description
Updated README to clarify Bichon's purpose and features.
2025-11-26 20:40:40 +08:00
rustmailer
33a16c1af9 Merge branch 'main' of https://github.com/rustmailer/bichon 2025-11-26 09:35:29 +08:00
rustmailer
1bfecc2ff1 update 2025-11-26 09:21:42 +08:00
rustmailer
a8282a95f3 Update README.md 2025-11-25 08:33:22 +08:00
rustmailer
9fe617aed7 Revise internationalization details in README
Updated internationalization section to clarify language support.
2025-11-25 00:11:10 +08:00
rustmailer
22e12b68a8 Add internationalization section to README
Added internationalization support details to README.
2025-11-25 00:08:50 +08:00
rustmailer
c6ee92aa3d Update thread-dialog.tsx 2025-11-24 22:44:46 +08:00
rustmailer
089b6885a7 fix(account): update "disabled" semantics
Modified the meaning of "disabled" accounts: they no longer connect to the IMAP server for syncing, but existing data remains accessible for search and queries.
2025-11-24 22:34:40 +08:00
rustmailer
e41b26ba0b update 2025-11-24 22:33:16 +08:00
rustmailer
ea81d0298b feat(i18n): add language switcher in top-right corner for multi-language support #9 2025-11-24 21:51:16 +08:00
rustmailer
5ebc7394d0 fix(account): prevent IMAP password from being overwritten when editing account #7 2025-11-24 19:11:52 +08:00
rustmailer
b97f2666a0 Revise CORS settings and access instructions
Updated CORS configuration and access instructions in README.
2025-11-23 01:30:56 +08:00
rustmailer
b36e63f530 Document Bichon encryption password setup
Added instructions for setting the Bichon encryption password via command-line and environment variable.
2025-11-23 00:43:41 +08:00
rustmailer
d4dfee8649 Merge pull request #10 from n3storm/patch-1
Update README with Bichon command requirements
2025-11-22 17:05:31 +08:00
rustmailer
1042fad6d0 feat: Add folder sync selection and All Mail exclusion logic 2025-11-22 17:02:20 +08:00
rustmailer
1e9a5fde51 bump versions 2025-11-22 17:01:39 +08:00
Néstor Díaz Valencia
c586bab84e Update README with Bichon command requirements
Added information about requirements for --bichon-root-dir and --bichon-cors-origins arguments when binary deploying.
2025-11-22 09:49:44 +01:00
rustmailer
6a5706e846 update 2025-11-22 02:17:06 +08:00
rustmailer
901615e031 update 2025-11-22 01:44:37 +08:00
rustmailer
f988c0df54 update 2025-11-22 00:44:08 +08:00
rustmailer
7db800005c update 2025-11-21 23:43:00 +08:00
rustmailer
a62130fb5c update 2025-11-21 23:11:46 +08:00
rustmailer
80a2667ae1 update 2025-11-21 22:50:03 +08:00
rustmailer
b6847be9f4 update 2025-11-21 22:42:44 +08:00
rustmailer
e8368a5776 update 2025-11-21 22:34:45 +08:00
rustmailer
7b2280f460 Update README.md 2025-11-21 20:46:46 +08:00
rustmailer
ef06cd2310 Update README.md 2025-11-21 20:25:31 +08:00
rustmailer
ede685ada8 update 2025-11-21 11:03:01 +08:00
rustmailer
d1be6aa6f2 update 2025-11-20 12:07:46 +08:00
rustmailer
f97e6958d5 update 2025-11-20 11:55:58 +08:00
rustmailer
227da127ae update 2025-11-20 11:46:01 +08:00
rustmailer
11619f8918 update 2025-11-20 11:29:01 +08:00
rustmailer
0bf308438f Update README.md 2025-11-20 11:23:08 +08:00
rustmailer
d62d4d3b47 update 2025-11-20 11:07:51 +08:00
rustmailer
b05bb4f48e ci: add aarch64-unknown-linux-gnu build and multi-arch Docker support #2 2025-11-20 10:57:05 +08:00
rustmailer
1f77ed91a7 fix(ui): mailbox email details sheet zoom and close actions not working #5 2025-11-19 22:07:40 +08:00
rustmailer
57b0ad946c update 2025-11-19 20:02:22 +08:00
rustmailer
ad02c76849 fix(ui): Add a scrollbar when selecting a mailbox, as the mailbox list can be very long. #4 2025-11-19 19:32:04 +08:00
rustmailer
e18b01dafb Update README.md 2025-11-19 10:16:39 +08:00
rustmailer
152478b767 Add curl installation to Dockerfile 2025-11-19 09:44:31 +08:00
rustmailer
77aae07167 Merge pull request #1 from khadanja/patch-1
Update envrionment variable names in .env & Readme
2025-11-19 09:34:34 +08:00
rustmailer
b607ee473a Update README.md 2025-11-19 09:26:35 +08:00
rustmailer
4f4fe6bbbc Update README.md 2025-11-19 09:15:23 +08:00
khadanja
750c56c5e8 Update envrionment variable names 2025-11-19 14:14:35 +13:00
khadanja
d2575862ad Update environment variables names 2025-11-19 14:13:37 +13:00
rustmailer
7c7c7ee7f3 Update README.md 2025-11-19 04:27:11 +08:00
rustmailer
c1ca64d606 Create FUNDING.yml 2025-11-19 04:03:23 +08:00
rustmailer
8f2469cb36 Update README.md 2025-11-19 04:02:20 +08:00
rustmailer
4e790f1512 Update README.md 2025-11-19 03:57:00 +08:00
rustmailer
5fd05fb154 Update README.md 2025-11-19 03:54:25 +08:00
rustmailer
3ab32f6a38 update 2025-11-19 03:32:52 +08:00
166 changed files with 19175 additions and 4247 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: rustmailer
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

21
.github/ISSUE_TEMPLATE/bug.md.md vendored Normal file
View File

@@ -0,0 +1,21 @@
---
name: Bug Report
about: Report a problem you encountered
title: "[BUG] "
labels: ["bug"]
assignees: ""
---
> **Please write and communicate in English.**
### Version
Which version are you using?
### Steps to Reproduce
Describe the steps to reproduce the issue clearly.
### Issue Description
What is the problem you encountered?
### Screenshots or Logs (optional)
Attach any screenshots or logs if available.

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

18
.github/ISSUE_TEMPLATE/feature.md vendored Normal file
View File

@@ -0,0 +1,18 @@
---
name: Feature Request
about: Suggest a new feature or improvement
title: "[FEATURE] "
labels: ["enhancement"]
assignees: ""
---
> **Please write and communicate in English.**
### Description
What feature would you like to see?
### Purpose / Use Case
Why is this feature needed? What problem does it solve?
### Additional Information (optional)
Any extra ideas or context.

15
.github/ISSUE_TEMPLATE/other.md vendored Normal file
View File

@@ -0,0 +1,15 @@
---
name: Other Issue
about: Any other question or topic
title: ""
labels: ["question"]
assignees: ""
---
> **Please write and communicate in English.**
### Description
Describe your question or topic.
### Additional Information (optional)
Provide any additional context if needed.

View File

@@ -25,6 +25,10 @@ jobs:
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
- target: aarch64-apple-darwin
os: macos-latest
steps:
- name: Checkout code
@@ -56,11 +60,19 @@ jobs:
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build aarch64 Rust backend
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
cargo install cross --force
cross build --release --features vendored-openssl --target=${{ matrix.target }}
- name: Build Rust backend
run: cargo build --release --features vendored-openssl --target=${{ matrix.target }}
if: matrix.target != 'aarch64-unknown-linux-gnu'
run: |
cargo build --release --features vendored-openssl --target=${{ matrix.target }}
- name: Strip binary (Linux and macOS)
if: matrix.os != 'windows-latest'
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
run: |
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
@@ -144,15 +156,31 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Download Linux musl artifact
- name: Download Linux x86_64 artifact
uses: actions/download-artifact@v4
with:
name: x86_64-unknown-linux-musl
name: x86_64-unknown-linux-gnu
path: artifacts
- name: Download Linux aarch64 artifact
uses: actions/download-artifact@v4
with:
name: aarch64-unknown-linux-gnu
path: artifacts
- name: Prepare Docker context
- name: Extract amd64 binary
run: |
tar -xzf artifacts/${{ env.BINARY_NAME }}-*.tar.gz -C docker
mkdir -p docker/amd64
AMD64_FILE=$(ls artifacts | grep x86_64-unknown-linux-gnu.tar.gz)
echo "Extracting $AMD64_FILE"
tar -xzf artifacts/$AMD64_FILE -C docker/amd64
- name: Extract arm64 binary
run: |
mkdir -p docker/arm64
ARM64_FILE=$(ls artifacts | grep aarch64-unknown-linux-gnu.tar.gz)
echo "Extracting $ARM64_FILE"
tar -xzf artifacts/$ARM64_FILE -C docker/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -168,6 +196,7 @@ jobs:
with:
context: ./docker
push: true
platforms: linux/amd64,linux/arm64
tags: |
rustmailer/bichon:${{ github.ref_name }}
rustmailer/bichon:latest

2391
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "bichon"
version = "0.0.1"
version = "0.1.4"
edition = "2021"
[[bin]]
@@ -20,7 +20,7 @@ codegen-units = 1
[dependencies]
chrono = "0.4.42"
clap = { version = "4.5.51", features = ["derive", "env"] }
clap = { version = "4.5.53", features = ["derive", "env"] }
mimalloc = "0.1.48"
native_db = "0.8.2"
itertools = "0.14.0"
@@ -39,28 +39,28 @@ 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.41"
tracing = "0.1.43"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
base64 = "0.22.1"
snafu = "0.8.9"
reqwest = { version = "0.12.24", default-features = false, features = [
"json",
"stream",
"native-tls",
# "rustls-tls",
#"native-tls",
"rustls-tls",
"blocking",
"socks",
] }
tokio-socks = "0.5.2"
http = "1.3.1"
http = "1.4.0"
regex = "1.12.2"
email_address = "0.2.9"
futures = "0.3.31"
utf7-imap = "0.3.2"
imap-proto = "0.16.6"
mail-parser = { version = '0.11.1', features = ["serde"] }
mail-send = "0.5.2"
# mail-send = "0.5.2"
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring",
"tls12",
@@ -83,9 +83,9 @@ async-imap = { version = "0.11.1", default-features = false, features = [
] }
webpki-roots = "1.0.4"
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
rustls-pki-types = "1.13.0"
rustls-pki-types = "1.13.1"
tokio-io-timeout = "1.2.1"
bb8 = "0.9.0"
bb8 = "0.9.1"
semver = "1.0.27"
governor = "0.10.2"
lru = "0.16.2"
@@ -106,7 +106,7 @@ openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
gethostname = "1.1.0"
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
itoa = "1.0.15"
html2text = "0.16.2"
html2text = "0.16.4"
bytes = "1.11.0"
[dev-dependencies]
#bincode = "1.3.3"

465
README.md
View File

@@ -0,0 +1,465 @@
<div align="center">
<h1 align="center">
<img width="200" height="175" alt="image" src="https://github.com/user-attachments/assets/06dc3b67-7d55-4a93-a3de-8b90951c575b" />
<br>
Bichon
<br>
</h1>
<h3 align="center">
A lightweight, high-performance Rust email archiver with WebUI
</h3>
<p style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
<a href="https://github.com/rustmailer/bichon/releases">
<img src="https://img.shields.io/github/v/release/rustmailer/bichon" alt="Release">
</a>
<a href="https://hub.docker.com/r/rustmailer/bichon">
<img src="https://img.shields.io/docker/v/rustmailer/bichon?label=docker" alt="Docker">
</a>
<a href="LICENSE">
<img src="https://img.shields.io/badge/license-AGPLv3-blue.svg" alt="License">
</a>
<a href="https://deepwiki.com/rustmailer/bichon"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
<a href="https://discord.gg/Bq4M2cDmF4">
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
</a>
<a href="https://x.com/rustmailer">
<img src="https://img.shields.io/twitter/follow/rustmailer?style=social" alt="Follow on X">
</a>
</p>
</div>
Bichon is an open-source email archiving system that **synchronizes emails from IMAP servers**, **indexes them for full-text search**, and provides a **REST API** for programmatic access.
**Unlike email clients**, Bichon is designed for **archiving and searching** rather than sending/receiving emails. It runs as a **standalone server application** that continuously synchronizes configured email accounts and maintains a **searchable local archive**.
Built in Rust, it requires no external dependencies and provides fast, efficient email archiving, management, and search through a built-in WebUI. Its name is inspired by the puppy my daughter adopted last month.
## Key Differences from Email Clients
### Core Comparison
| Feature | Email Clients | Bichon |
|---------|---------------|--------|
| **Primary Purpose** | Send/receive emails, real-time communication | Archive, search, manage historical emails |
| **Sending Capability** | ✅ Supports sending emails | ❌ No email sending support |
| **Runtime Mode** | Desktop/mobile applications | Server-side application |
| **Data Storage** | Local cache + server | Local archive store |
| **Search Capability** | Basic search | Full-text indexing, advanced search |
| **API Interface** | Typically not provided | Complete REST API |
| **Multi-account Management** | Limited | Supports unified search across accounts |
## 🚀 Features
### ⚡ Lightweight & Standalone
- Pure Rust, single-machine application.
- No external database required.
- Includes **WebUI** for intuitive management.
### 📬 Multi-Account Management
- Synchronize and download emails from multiple accounts.
- Flexible selection: by **date range**, **number of emails**, or **specific mailboxes**.
### 🔑 IMAP & OAuth2 Authentication
- Supports **IMAP password** or **OAuth2** login.
- Built-in WebUI for **OAuth2 authorization**, including **automatic token refresh** (e.g., Gmail, Outlook).
- Supports **network proxy** for IMAP and OAuth2.
- Automatic IMAP server discovery and configuration.
### 🔍 Unified Multi-Account Search
- Powerful search across all accounts:
**account**, **mailbox**, **sender**, **attachment name**, **has attachments**, **size**, **date**, **subject**, **body**.
### 🏷️ Tags & Facets
- Organize archived emails using **tags** backed by Tantivy **facets**.
- Efficiently filter and locate emails based on these facet-based tags.
### 💾 Compressed & Deduplicated Storage
- Store emails efficiently with **transparent compression** and **deduplication**—emails can be read directly without any extra steps.
### 📂 Email Management & Viewing
- Bulk cleanup of local archives.
- Download emails as **EML** or **attachments separately**.
- View and browse emails directly.
- View the full **conversation thread** of any email.
### 📊 Dashboard & Analytics
- Visualize email statistics: **counts**, **time distribution**, **top senders**, **largest emails**, **account rankings**.
### 🌐 Internationalization (i18n)
* WebUI fully supports **17 languages** for all interface elements.
* Backend responses (e.g., system messages, API data) are **not yet internationalized**.
* Frontend is ready to support more languages in the future with minimal effort.
### 🛠️ OpenAPI Support
- Provides **OpenAPI documentation**.
- **Access token authentication** for programmatic access.
## 🐾 Why Create Bichon?
A few months ago, I released **rustmailer**, an email API middleware:
https://github.com/rustmailer/rustmailer
Since then, Ive received many emails asking whether it could also archive emails, perform unified search, and support full-text indexing—not just querying recipients.
But rustmailer was designed as a middleware focused on providing API services.
Adding archiving and full-text search would complicate its core purpose and go far beyond its original scope.
Meanwhile, I realized that email archiving itself only requires a small portion of rustmailers functionality, plus a search engine.
With that combination, building a dedicated, efficient archiver becomes much simpler.
Using the experience gained from rustmailer, I designed and built **Bichon** in less than two weeks, followed by another two weeks of testing and optimization.
It has now reached a stable, usable state—and I decided to release it publicly.
**Bichon is completely free**.
You can download and use it however you like.
Its not perfect, but I hope it brings you value.
## 📸 Snapshot
<img width="1914" height="904" alt="image" src="https://github.com/user-attachments/assets/3a456999-e4eb-441e-9052-3a727dea66a0" />
<img width="1900" height="907" alt="image" src="https://github.com/user-attachments/assets/95db0a05-4b55-4e18-b418-9d40361d6fea" />
<img width="1912" height="904" alt="image" src="https://github.com/user-attachments/assets/96b0ebc2-4778-452b-891f-dc9acf8e381f" />
<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="1913" height="909" alt="image" src="https://github.com/user-attachments/assets/6fd54cb0-c86f-4ceb-a955-c81107614fc4" />
<img width="1916" height="814" alt="image" src="https://github.com/user-attachments/assets/6a079d98-ff6c-46f4-9ec6-e76d320bff5d" />
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=rustmailer/bichon&type=date&legend=top-left)](https://www.star-history.com/#rustmailer/bichon&type=date&legend=top-left)
## 🚀 Quick Start
### Docker Deployment (Recommended)
```bash
# Pull the image
docker pull rustmailer/bichon:latest
# Create data directory
mkdir -p ./bichon-data
# Run container
docker run -d \
--name bichon \
-p 15630:15630 \
-v $(pwd)/bichon-data:/data \
-e BICHON_LOG_LEVEL=info \
-e BICHON_ROOT_DIR=/data \
rustmailer/bichon:latest
```
## CORS Configuration (Important for Browser Access)
Starting from **v0.1.4**, Bichon changes how `BICHON_CORS_ORIGINS` works:
### **🔄 New Behavior in v0.1.4**
* If **`BICHON_CORS_ORIGINS` is not set**, Bichon now **allows all origins**.
This makes local testing and simple deployments much easier.
* If you **do set** `BICHON_CORS_ORIGINS`, then **you must explicitly list each allowed origin**.
* `*` is **not supported** and will **not work** — you must provide exact URLs.
#### How CORS Matching Works
When a browser accesses Bichon, it will send an `Origin` header.
* **Incoming Origin** = the exact address the browser is using
* **Configured origins** = the list you passed to `BICHON_CORS_ORIGINS`
If Configured origins does not contain the Incoming Origin exactly as a full string match, the browser request will be rejected.
Example debug log:
```
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Incoming Origin = "http://localhost:15630"
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Configured origins = ["http://192.168.3.2:15630"]
```
In this example:
* Browser is using `http://localhost:15630`
* But the configured origin is `http://192.168.3.2:15630`
→ **CORS will fail**, and you can immediately see why.
#### When Should You Configure CORS?
It is strongly recommended to configure CORS in production environments to ensure that only trusted browser origins can access Bichon.
If you want to access Bichon from a browser:
* Add the exact **IP** with port
* Or the exact **hostname** with port
* Or the **domain** (port optional if it's 80)
Examples:
```
http://192.168.1.16:15630
http://myserver.local:15630
http://mydomain.com
```
If you access Bichon in **multiple different ways**, list all of them:
```
-e BICHON_CORS_ORIGINS="http://192.168.1.16:15630,http://myserver.local:15630,http://mydomain.com"
```
> **Do not add a trailing slash**
> (`http://192.168.1.16:15630/` will not match)
>
> **Do not use `*`**, it is not supported.
#### How to Enable Debug Logs (Highly Recommended for CORS Issues)
Set environment variable:
```
BICHON_LOG_LEVEL=debug
```
Or via command-line:
```
--bichon-log-level debug
```
Default is `info`, so CORS logs will not appear unless debug logging is enabled.
---
#### ⚠️ Note on Running Bichon in a Container
> ⚠️ **Note:** If you are running Bichon in a container (via **Docker Compose** or **docker run**), be careful with **quotes in environment variable values**.
For example, **do not** write:
```bash
-e BICHON_CORS_ORIGINS="http://localhost:15630,http://myserver.local:15630"
```
* The outer quotes (`"`) will be passed literally into the container and may cause CORS misconfiguration.
**Correct way:**
```bash
-e BICHON_CORS_ORIGINS=http://localhost:15630,http://myserver.local:15630
```
Or using YAML literal style for Docker Compose:
```yaml
environment:
BICHON_CORS_ORIGINS: |
http://localhost:15630,http://myserver.local:15630
```
This ensures that the configured origins are interpreted correctly inside the container.
> ⚠️ **Note:** This fucking problem I actually didnt know about myself; thanks to [gall-1](https://github.com/gall-1) for pointing it out.
### Binary Deployment
Download the appropriate binary for your platform from the [Releases](https://github.com/rustmailer/bichon/releases) page:
- Linux (GNU): `bichon-x.x.x-x86_64-unknown-linux-gnu.tar.gz`
- Linux (MUSL): `bichon-x.x.x-x86_64-unknown-linux-musl.tar.gz`
- macOS: `bichon-x.x.x-x86_64-apple-darwin.tar.gz`
- Windows: `bichon-x.x.x-x86_64-pc-windows-msvc.zip`
Extract and run:
```bash
# Linux/macOS
./bichon --bichon-root-dir /tmp/bichon-data
# Windows
.\bichon.exe --bichon-root-dir e:\bichon-data
```
* --bichon-root-dir argument is required and must be an absolute path.
* If you are accessing Bichon from a proxy domain **mydomain** argument --bichon-cors-origins="https://mydomain" is required.
## Setting the Bichon Encryption Password
Bichon uses an encryption password to secure sensitive data. **You must set it before first use**, when no data exists.
Once set, it **cannot be changed**. Changing it later will make all encrypted data unreadable. To start over, you would need to **reinitialize Bichon and clear all emails and metadata**.
### How to Set the Password
You can set the password **via command-line or environment variable**:
### Command-Line
```bash
bichon --bichon-encrypt-password "your-strong-password"
```
### Environment Variable
```bash
export BICHON_ENCRYPT_PASSWORD="your-strong-password"
bichon
```
**Tip:** Use a strong, secure password and keep it safe, as it cannot be changed later.
## 🔑 Root User Login Information
**Bichon currently supports a single Root user login for system access and management.**
### First Login and Enabling Access
To enable the login feature, you must specify a command-line argument or set an environment variable when starting Bichon.
#### 1\. Command-Line Argument
Add the `--bichon-enable-access-token` flag to your startup command:
```bash
# Linux/macOS Binary Deployment Example
./bichon --bichon-root-dir /tmp/bichon-data --bichon-enable-access-token
```
#### 2\. Environment Variable (Recommended for Docker)
Set the environment variable `BICHON_ENABLE_ACCESS_TOKEN` to `true`:
```bash
# Docker Deployment Example
docker run -d \
--name bichon \
-p 15630:15630 \
-v $(pwd)/bichon-data:/data \
-e BICHON_LOG_LEVEL=info \
-e BICHON_ROOT_DIR=/data \
-e BICHON_ENABLE_ACCESS_TOKEN=true \
rustmailer/bichon:latest
```
### Default Credentials
* **Initial Login Account:** `root`
* **Initial Password:** `root`
### Changing the Password
**It is strongly recommended that you change the default password immediately after your first login.**
You can change the password via the WebUI:
1. Log in to the WebUI.
2. Navigate to the **Settings** page.
3. Use the **Reset Root Password** option to modify your password.
## 📖 Documentation
> Under construction. Documentation will be available soon.
[Bichon Wiki](https://github.com/rustmailer/bichon/wiki).
## FAQ
please see the FAQ in the project Wiki:
👉 [https://github.com/rustmailer/bichon/wiki/FAQ](https://github.com/rustmailer/bichon/wiki/FAQ-(Frequently-Asked-Questions))
## 🛠️ Tech Stack
- **Backend**: Rust + Poem
- **Frontend**: React + TypeScript + Vite + ShadCN UI
- **Core Engine (Storage & Search)**: Tantivy
- Acts as both the primary storage for email content and the full-text search index. This unified approach ensures high performance and eliminates data redundancy.
- **Metadata Storage**: Native_DB
- Used exclusively for lightweight configuration and account metadata.
- **Email Protocols**: IMAP (Supports standard Password & OAuth2)
## 🤝 Contributing
Contributions of all kinds are welcome!
Whether youd like to submit code, report a bug, or share practical suggestions that can help improve the project, your input is highly appreciated.
Feel free to open an Issue or a Pull Request anytime. You can also reach out on Discord if youd like to discuss ideas or improvements.
<a href="https://discord.gg/Bq4M2cDmF4">
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
</a>
## 🧑‍💻 Developer Guide
To build or contribute to Bichon, the following environment is recommended:
### Prerequisites
- **Rust**: Use the latest stable toolchain for best compatibility and performance.
- **Node.js**: Version **20+** is required.
- **pnpm**: Recommended package manager for the WebUI.
### Steps
#### 1. Clone the repository
```bash
git clone https://github.com/rustmailer/bichon.git
cd bichon
```
#### 2. Build the WebUI
```bash
cd web
pnpm install
pnpm run build
```
Run the WebUI in development mode if needed:
```bash
pnpm run dev
```
#### 3. Build or Run the Backend
After the WebUI is built, return to the project root:
```bash
cd ..
cargo build
```
Or run directly:
```bash
cargo run -- --bichon-root-dir e:\bichon-data
```
`--bichon-root-dir` specifies the directory where **all Bichon data** will be stored.
### WebUI Access
* The WebUI runs on **[http://localhost:15630](http://localhost:15630)** by default.
* **HTTPS is not enabled** in development or default builds.
<cite/>
## 📄 License
This project is licensed under [AGPLv3](LICENSE).
## 🔗 Links
- [Docker Hub](https://hub.docker.com/r/rustmailer/bichon)
- [Issue Tracker](https://github.com/rustmailer/bichon/issues)
- [Discord](https://discord.gg/Bq4M2cDmF4)
## 💖 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.
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!
[![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)

View File

@@ -1,6 +1,7 @@
# Use Debian bullseye-slim as the base image for newer OpenSSL
FROM ubuntu:24.04
ARG CRATE_VERSION
ARG TARGETARCH
# Set maintainer metadata
LABEL maintainer="rustmailer <rustmailer.git@gmail.com>"
@@ -11,14 +12,13 @@ LABEL description="Dockerized Bichon service"
WORKDIR /opt/bichon
# Copy compiled binary (ensure it's statically linked or compatible with bullseye)
COPY bichon /opt/bichon/bichon
COPY LICENSE /opt/bichon/
COPY ${TARGETARCH}/bichon /opt/bichon/bichon
COPY ${TARGETARCH}/LICENSE /opt/bichon/
# Set proper permissions
RUN chmod +x /opt/bichon/bichon
# Install ca-certificates to ensure HTTPS certificate verification works correctly
RUN apt update && apt install -y ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt update && apt install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
# Create data directory
RUN mkdir -p /data

32
env/rustmailer.env vendored
View File

@@ -1,44 +1,44 @@
# Log level: trace, debug, info, warn, error
RUSTMAILER_LOG_LEVEL=info
BICHON_LOG_LEVEL=info
# HTTP server port
RUSTMAILER_HTTP_PORT=15630
BICHON_HTTP_PORT=15630
# Public URL for this RustMailer instance
RUSTMAILER_PUBLIC_URL=http://localhost:15630
# Public URL for this BICHON instance
BICHON_PUBLIC_URL=http://localhost:15630
# Enable ANSI-colored logs (for terminal output)
RUSTMAILER_ANSI_LOGS=false
BICHON_ANSI_LOGS=false
# Enable JSON-formatted logs (for machine parsing)
RUSTMAILER_JSON_LOGS=false
BICHON_JSON_LOGS=false
# Persist logs to files
RUSTMAILER_LOG_TO_FILE=false
BICHON_LOG_TO_FILE=false
# Max number of server log files to retain
RUSTMAILER_MAX_SERVER_LOG_FILES=5
BICHON_MAX_SERVER_LOG_FILES=5
# Encryption password for stored secrets (change this in production!)
RUSTMAILER_ENCRYPT_PASSWORD=change-this-default-password-now
BICHON_ENCRYPT_PASSWORD=change-this-default-password-now
# Root directory for persistent storage (adjust to your environment)
RUSTMAILER_ROOT_DIR=/data/bichon-data
BICHON_ROOT_DIR=/data/bichon-data
# Enable API access token validation
RUSTMAILER_ENABLE_ACCESS_TOKEN=false
BICHON_ENABLE_ACCESS_TOKEN=false
# IP address to bind the HTTP and gRPC servers to (default: 0.0.0.0)
RUSTMAILER_BIND_IP=
BICHON_BIND_IP=
# Comma-separated list of allowed CORS origins (e.g. https://app.example.com)
RUSTMAILER_CORS_ORIGINS=
BICHON_CORS_ORIGINS=
# Cache duration (in seconds) for CORS preflight responses
RUSTMAILER_CORS_MAX_AGE=86400
BICHON_CORS_MAX_AGE=86400
# Enable HTTPS for REST API server
RUSTMAILER_ENABLE_REST_HTTPS=false
BICHON_ENABLE_REST_HTTPS=false
# Enable HTTP response compression (gzip or brotli)
RUSTMAILER_HTTP_COMPRESSION_ENABLED=true
BICHON_HTTP_COMPRESSION_ENABLED=true

View File

@@ -70,7 +70,6 @@ pub struct AuthConfig {
///
/// Users should provide a plaintext password (1 to 256 characters).
/// The server will encrypt the password using AES-256-GCM and securely store it.
/// The plaintext password is never stored, so users must remember it for authentication.
#[oai(validator(max_length = 256, min_length = 1))]
pub password: Option<String>,
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use native_db::*;
use native_model::{native_model, Model};
@@ -56,7 +55,7 @@ use crate::modules::rest::response::DataPage;
use crate::modules::token::AccessToken;
use crate::raise_error;
pub type AccountModel = AccountV1;
pub type AccountModel = AccountV2;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AccountType {
@@ -87,11 +86,41 @@ pub struct AccountV1 {
pub updated_at: i64,
pub use_proxy: Option<u64>,
}
impl AccountV1 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 2, from = AccountV1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV2 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
@@ -110,12 +139,14 @@ impl AccountV1 {
updated_at: utc_now!(),
use_proxy: request.use_proxy,
folder_limit: request.folder_limit,
use_dangerous: request.use_dangerous,
pgp_key: request.pgp_key,
})
}
pub async fn check_account_active(account_id: u64) -> BichonResult<AccountModel> {
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
let account =
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
.await?
.ok_or_else(|| {
raise_error!(
@@ -124,12 +155,12 @@ impl AccountV1 {
)
})?;
if !account.enabled {
return Err(raise_error!(
format!("Account id='{account_id}' is disabled"),
ErrorCode::AccountDisabled
));
}
// if !account.enabled {
// return Err(raise_error!(
// format!("Account id='{account_id}' is disabled"),
// ErrorCode::AccountDisabled
// ));
// }
Ok(account)
}
@@ -145,7 +176,7 @@ impl AccountV1 {
}
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
.await
}
@@ -199,7 +230,7 @@ impl AccountV1 {
async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
}).await
}
@@ -229,7 +260,7 @@ impl AccountV1 {
sync_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -244,7 +275,7 @@ impl AccountV1 {
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -259,7 +290,7 @@ impl AccountV1 {
capabilities: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -278,7 +309,7 @@ impl AccountV1 {
let result = list_all_impl(DB_MANAGER.meta_db())
.await?
.into_iter()
.filter(|a: &AccountModel| a.enabled)
//.filter(|a: &AccountModel| a.enabled)
.map(|account: AccountModel| MinimalAccount {
id: account.id,
email: account.email,
@@ -288,7 +319,7 @@ impl AccountV1 {
}
pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id)
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id)
.await
}
@@ -318,17 +349,26 @@ impl AccountV1 {
}
if let Some(name) = &request.name {
new.name = Some(name.clone());
if name.trim().is_empty() {
new.name = None;
} else {
new.name = Some(name.clone());
}
}
if matches!(old.account_type, AccountType::IMAP) {
if let Some(imap) = &request.imap {
let mut new_imap = imap.clone();
if let Some(password) = &new_imap.auth.password {
let encrypted_password = encrypt!(password)?;
new_imap.auth.password = Some(encrypted_password);
if let Some(current_imap) = &mut new.imap {
current_imap.host = imap.host.clone();
current_imap.port = imap.port.clone();
current_imap.encryption = imap.encryption.clone();
current_imap.auth.auth_type = imap.auth.auth_type.clone();
if let Some(password) = &imap.auth.password {
let encrypted_password = encrypt!(password)?;
current_imap.auth.password = Some(encrypted_password);
}
current_imap.use_proxy = imap.use_proxy;
}
new.imap = Some(new_imap);
}
if let Some(folder_names) = request.sync_folders {
@@ -351,7 +391,62 @@ impl AccountV1 {
if let Some(enabled) = request.enabled {
new.enabled = enabled;
}
if let Some(use_dangerous) = request.use_dangerous {
new.use_dangerous = use_dangerous;
}
if let Some(pgp_key) = request.pgp_key {
new.pgp_key = Some(pgp_key);
}
new.updated_at = utc_now!();
Ok(new)
}
}
impl From<AccountV1> for AccountV2 {
fn from(value: AccountV1) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: false,
pgp_key: None,
}
}
}
impl From<AccountV2> for AccountV1 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
}
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::modules::account::entity::ImapConfig;
@@ -43,6 +42,8 @@ pub struct AccountCreateRequest {
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
pub sync_interval_min: Option<i64>,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountCreateRequest {
@@ -132,6 +133,10 @@ pub struct AccountUpdateRequest {
/// - If `None` or not provided, the client will connect directly to the API server.
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
pub use_proxy: Option<u64>,
pub use_dangerous: Option<bool>,
pub pgp_key: Option<String>,
}
impl AccountUpdateRequest {

View File

@@ -69,7 +69,7 @@ impl AccountRunningState {
errors: vec![],
is_initial_sync_completed: false,
progress: None,
initial_sync_start_time: None,
initial_sync_start_time: Some(utc_now!()),
initial_sync_end_time: None,
initial_sync_failed_time: None,
};
@@ -125,14 +125,14 @@ impl AccountRunningState {
.await
}
pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
updated.initial_sync_start_time = Some(utc_now!());
Ok(updated)
})
.await
}
// pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// updated.initial_sync_start_time = Some(utc_now!());
// Ok(updated)
// })
// .await
// }
pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {

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::{
@@ -46,13 +45,13 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
let start_time = Instant::now();
let account_id = account.id;
let sync_type = determine_sync_type(account).await?;
if matches!(sync_type, SyncType::SkipSync) {
return Ok(());
}
let remote_mailboxes = get_sync_folders(account).await?;
if matches!(sync_type, SyncType::InitialSync) {
AccountRunningState::set_initial_sync_start(account_id).await?;
AccountRunningState::add(account.id).await?;
// AccountRunningState::set_initial_sync_start(account_id).await?;
let result = match &account.date_since {
Some(date_since) => {
rebuild_cache_since_date(account, &remote_mailboxes, date_since).await

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, state::AccountRunningState},
@@ -51,10 +50,7 @@ pub async fn determine_sync_type(account: &AccountModel) -> BichonResult<SyncTyp
SyncType::SkipSync
}
}
None => {
AccountRunningState::add(account.id).await?;
SyncType::InitialSync
}
None => SyncType::InitialSync,
})
}

View File

@@ -16,19 +16,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use super::error::code::ErrorCode;
use super::error::BichonError;
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
use mail_send::mail_builder::headers::address::Address as SmtpAddress;
use mail_send::mail_builder::headers::address::EmailAddress as SmtpEmailAddress;
use poem::error::ResponseError;
use poem::Body;
use poem::{http::StatusCode, Error, Response};
use poem_openapi::Object;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::ops::Deref;
use tracing::error;
@@ -53,38 +48,6 @@ pub struct Addr {
pub address: Option<String>,
}
impl Addr {
pub fn parse(s: &str) -> Self {
let re = Regex::new(r#"(?:(?P<name>.*)\s*)?<(?P<email>[^<>]+)>"#).unwrap();
if let Some(caps) = re.captures(s) {
let name: Option<String> = caps.name("name").map(|m| m.as_str().trim().into());
let email: Option<String> = caps.name("email").map(|m| m.as_str().trim().into());
Addr {
name: if let Some(n) = name {
if n.is_empty() {
None
} else {
Some(n)
}
} else {
None
},
address: email,
}
} else {
let s_trimmed = s.trim();
Addr {
name: None,
address: if s_trimmed.is_empty() {
None
} else {
Some(s_trimmed.into())
},
}
}
}
}
impl std::fmt::Display for Addr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (&self.name, &self.address) {
@@ -129,50 +92,6 @@ impl<'x> From<&ImapAddress<'x>> for AddrVec {
}
}
impl<'x> From<SmtpEmailAddress<'x>> for Addr {
fn from(email: SmtpEmailAddress<'x>) -> Self {
Addr {
name: email.name.map(|n| n.into_owned()),
address: Some(email.email.into_owned()),
}
}
}
impl<'x> From<&SmtpAddress<'x>> for AddrVec {
fn from(address: &SmtpAddress<'x>) -> Self {
fn collect_addresses<'x>(address: &SmtpAddress<'x>, result: &mut Vec<Addr>) {
match address {
SmtpAddress::Address(email) => {
let addr = Addr::from(email.clone());
result.push(addr);
}
SmtpAddress::Group(group) => {
for addr in &group.addresses {
collect_addresses(addr, result);
}
}
SmtpAddress::List(list) => {
for addr in list {
collect_addresses(addr, result);
}
}
}
}
let mut addresses = Vec::new();
collect_addresses(address, &mut addresses);
AddrVec(addresses)
}
}
impl<'x> From<Addr> for SmtpAddress<'x> {
fn from(addr: Addr) -> Self {
SmtpAddress::Address(SmtpEmailAddress {
name: addr.name.map(Cow::Owned),
email: Cow::Owned(addr.address.unwrap_or_default()),
})
}
}
// #[derive(Serialize)]
// pub struct ErrorResponse {
// pub message: String,

View File

@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountType;
use crate::modules::context::Initialize;
use crate::modules::error::code::ErrorCode;
use crate::raise_error;
@@ -33,8 +33,7 @@ use dashmap::DashMap;
use std::sync::{Arc, LazyLock};
use tracing::info;
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> =
LazyLock::new(EmailClientExecutors::new);
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> = LazyLock::new(EmailClientExecutors::new);
pub struct EmailClientExecutors {
start_at: i64,
@@ -88,15 +87,17 @@ impl EmailClientExecutors {
pub async fn start_account_syncers(&self) -> BichonResult<()> {
let accounts = AccountModel::list_all().await?;
let active_accounts: Vec<AccountModel> =
accounts.into_iter().filter(|a| a.enabled).collect();
let active_accounts: Vec<AccountModel> = accounts
.into_iter()
.filter(|a| a.enabled && matches!(a.account_type, AccountType::IMAP))
.collect();
if active_accounts.is_empty() {
info!("No active accounts found for account initialization.");
return Ok(());
}
info!(
"System has {} active accounts to initialize.",
"System has {} active IMAP accounts to initialize.",
active_accounts.len()
);
for account in active_accounts {

View File

@@ -16,12 +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 poem_openapi::Object;
use serde::{Deserialize, Serialize};
use tantivy::{schema::Value, TantivyDocument};
use crate::{
bichon_version,
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
@@ -45,6 +45,8 @@ pub struct DashboardStats {
pub with_attachment_count: u64, // Emails with attachments
pub without_attachment_count: u64, // Emails without attachments
pub top_largest_emails: Vec<LargestEmail>, // Top 10 largest emails
pub system_version: String, // The semantic version string of the currently running backend service
pub commit_hash: String, // Git commit hash used to build this system version
}
impl DashboardStats {
@@ -57,6 +59,8 @@ impl DashboardStats {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.system_version = bichon_version!().to_string();
stat.commit_hash = env!("GIT_HASH").to_string();
Ok(stat)
}
}

View File

@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::cache::imap::MAILBOX_MODELS;
use crate::modules::error::{code::ErrorCode, BichonError};
use crate::modules::settings::cli::SETTINGS;
@@ -71,6 +71,8 @@ impl DatabaseManager {
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;

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::modules::account::migration::AccountV1;
use crate::modules::account::migration::{AccountV1, AccountV2};
use crate::modules::autoconfig::CachedMailSettings;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -63,6 +62,7 @@ impl ModelsAdapter {
self.register_model::<SystemSetting>();
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<AccountV2>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();

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::common::AddrVec;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -33,12 +32,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
.map(|d| d.timestamp_millis())
.unwrap_or(0);
let uid = fetch.uid.unwrap_or(0);
let size = fetch.size.unwrap_or(0);
let body = fetch
.body()
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
let size = fetch.size.unwrap_or(body.len() as u32);
let message = MessageParser::new().parse(body).ok_or_else(|| {
raise_error!(
"Email header parse result is not available".into(),
@@ -118,6 +117,92 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
Ok(envelope)
}
pub fn extract_envelope_from_eml(
body: &[u8],
account_id: u64,
mailbox_id: u64,
) -> BichonResult<Envelope> {
let uid = 0;
let size = body.len() as u32;
let message = MessageParser::new().parse(body).ok_or_else(|| {
raise_error!(
"Email header parse result is not available".into(),
ErrorCode::InternalError
)
})?;
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
from_read(html.as_bytes(), 0)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} else {
String::new()
};
let message_id = message
.message_id()
.map(String::from)
.unwrap_or(generate_message_id());
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let subject = message.subject().map(String::from).unwrap_or("".into());
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let cc: Option<Vec<String>> = message.cc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let to: Option<Vec<String>> = message.to().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let from = message
.from()
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
.and_then(|add| add.address)
.unwrap_or_else(|| "unknown".to_string());
let attachments: Vec<String> = message
.attachments()
.filter_map(|att| att.attachment_name())
.map(|name| name.to_string())
.collect();
let envelope = Envelope {
id: create_hash(account_id, &message_id),
message_id,
account_id,
mailbox_id,
uid,
subject,
text,
from,
to: to.unwrap_or_default(),
cc: cc.unwrap_or_default(),
bcc: bcc.unwrap_or_default(),
date,
internal_date: date,
size,
thread_id,
attachments,
tags: None,
};
Ok(envelope)
}
pub fn compute_thread_id(
in_reply_to: Option<String>,
references: Option<Vec<String>>,

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::imap::session::SessionStream;
use crate::{modules::error::BichonResult, raise_error};

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::entity::Encryption;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -100,15 +99,17 @@ impl Client {
encryption: &Encryption,
port: u16,
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<Self> {
let resolved_addr = Self::resolve_to_socket_addr(domain, port)?;
debug!("Attempting IMAP connection to {domain} ({resolved_addr}).");
match encryption {
Encryption::Ssl => {
Self::establish_secure_connection(resolved_addr, domain, use_proxy).await
Self::establish_secure_connection(resolved_addr, domain, use_proxy, dangerous).await
}
Encryption::StartTls => {
Self::establish_starttls_connection(resolved_addr, domain, use_proxy).await
Self::establish_starttls_connection(resolved_addr, domain, use_proxy, dangerous)
.await
}
Encryption::None => Self::establish_insecure_connection(resolved_addr, use_proxy).await,
}
@@ -118,11 +119,17 @@ impl Client {
address: SocketAddr,
server_hostname: &str,
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<Self> {
// Establish the TLS connection with the specified parameters
let tls_stream =
establish_tls_connection(address, server_hostname, alpn(address.port()), use_proxy)
.await?;
let tls_stream = establish_tls_connection(
address,
server_hostname,
alpn(address.port()),
use_proxy,
dangerous,
)
.await?;
let stats_stream = StatsWrapper::new(tls_stream);
// Wrap the TLS stream in a buffered writer for efficient IO
let buffered_stream = BufWriter::new(stats_stream);
@@ -137,7 +144,7 @@ impl Client {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.ok_or_else(|| {
raise_error!(
"failed to read greeting".into(),
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is SSL.".into(),
ErrorCode::ImapCommandFailed
)
})?;
@@ -180,6 +187,7 @@ impl Client {
address: SocketAddr,
server_hostname: &str,
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<Self> {
// Establish the initial TCP connection
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
@@ -197,7 +205,7 @@ impl Client {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.ok_or_else(|| {
raise_error!(
"failed to read greeting".into(),
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is STARTTLS.".into(),
ErrorCode::ImapCommandFailed
)
})?;
@@ -217,7 +225,7 @@ impl Client {
let buffered_tcp_stream = client.into_inner();
let tcp_stream = buffered_tcp_stream.into_inner();
// Wrap the TCP stream in TLS encryption
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream).await?;
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream, dangerous).await?;
// Wrap the TLS stream in a buffered writer
let buffered_stream = BufWriter::new(tls_stream);
// Create a SessionStream trait object for further communication

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::dispatcher::STATUS_DISPATCHER;
use crate::modules::account::entity::AuthType;
use crate::modules::account::migration::{AccountModel, AccountType};
@@ -29,7 +28,7 @@ use crate::modules::imap::client::Client;
use crate::modules::imap::oauth2::OAuth2;
use crate::modules::imap::session::SessionStream;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::{decrypt, raise_error};
use crate::{bichon_version, decrypt, raise_error};
use async_imap::Session;
use tracing::error;
@@ -51,7 +50,14 @@ impl ImapConnectionManager {
async fn create_client(&self, account: &AccountModel) -> BichonResult<Client> {
assert_eq!(account.account_type, AccountType::IMAP);
let imap = account.imap.as_ref().unwrap();
Client::connection(&imap.host, &imap.encryption, imap.port, imap.use_proxy).await
Client::connection(
&imap.host,
&imap.encryption,
imap.port,
imap.use_proxy,
account.use_dangerous,
)
.await
}
async fn authenticate(
@@ -61,6 +67,7 @@ impl ImapConnectionManager {
) -> BichonResult<Session<Box<dyn SessionStream>>> {
assert_eq!(account.account_type, AccountType::IMAP);
let imap = account.imap.as_ref().unwrap();
let username = account.name.clone().unwrap_or(account.email.clone());
match &imap.auth.auth_type {
AuthType::Password => {
let password = &imap.auth.password.clone().ok_or_else(|| {
@@ -71,7 +78,7 @@ impl ImapConnectionManager {
})?;
let password = decrypt!(&password)?;
client.login(&account.email, &password).await
client.login(&username, &password).await
}
AuthType::OAuth2 => {
let record = OAuth2AccessToken::get(self.account_id).await?;
@@ -83,7 +90,7 @@ impl ImapConnectionManager {
)
})?;
client
.authenticate(OAuth2::new(account.email.clone(), access_token))
.authenticate(OAuth2::new(username, access_token))
.await
}
}
@@ -137,6 +144,19 @@ impl ImapConnectionManager {
.await;
return Err(error);
}
if capabilities.has_str("ID") || capabilities.has_str("id") {
session
.id([
("name", Some("bichon")),
("version", Some(bichon_version!())),
("vendor", Some("rustmailer")),
])
.await
.map_err(|e| {
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
})?;
}
}
Err(error) => {
error!("Failed to fetch IMAP capabilities: {:#?}", error);

View File

@@ -16,19 +16,21 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use mail_parser::MessageParser;
use crate::{base64_encode_url_safe, modules::{account::entity::Encryption, imap::client::Client}};
use crate::{
base64_encode_url_safe,
modules::{account::entity::Encryption, imap::client::Client},
};
#[tokio::test]
async fn testxx() {
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
.unwrap();
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None)
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None, false)
.await
.unwrap();
let mut session = client.login("pollybase@zohomail.com", "xxx").await.unwrap();
let mut session = client.login("xx@zohomail.com", "xxx").await.unwrap();
session.select("INBOX").await.unwrap();
let result = session.uid_search("LARGER 1024").await.unwrap();
println!("{:#?}", result);

162
src/modules/import/mod.rs Normal file
View File

@@ -0,0 +1,162 @@
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use tantivy::doc;
use crate::{
base64_decode_url_safe,
modules::{
account::migration::{AccountModel, AccountType},
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
envelope::extractor::extract_envelope_from_eml,
error::{code::ErrorCode, BichonResult},
indexer::{
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
schema::SchemaTools,
},
utils::create_hash,
},
raise_error,
};
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct BatchEmlRequest {
pub account_id: u64,
pub mail_folder: String,
/// A list of emails in base64-encoded format. Each element represents one .eml file.
pub emls: Vec<String>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct FailedEmlDetail {
/// The 0-based index of the failed EML in the request list
pub index: usize,
/// The error message that caused the import to fail
pub error_message: String,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct BatchEmlResult {
/// Total number of emails processed
pub total: usize,
/// Number of emails successfully imported
pub success: usize,
/// Number of emails failed to import
pub failed: usize,
/// A list of details for failed imports
pub failed_details: Vec<FailedEmlDetail>,
}
pub struct ImportEmls;
impl ImportEmls {
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
let account = AccountModel::check_account_exists(request.account_id).await?;
if !account.enabled {
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
}
let mailbox_id = match account.account_type {
AccountType::IMAP => {
let all_mailboxes = MailBox::list_all(account.id).await?;
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
match mailbox {
Some(mailbox) => mailbox.id,
None => return Err(raise_error!(
format!("Mail folder '{}' not found for account ID {}. The target folder must exist before importing.",
request.mail_folder,
request.account_id).into(),
ErrorCode::ResourceNotFound
)),
}
},
AccountType::NoSync => {
let mailbox = MailBox {
id: create_hash(request.account_id, &request.mail_folder),
account_id: request.account_id,
name: request.mail_folder.clone(),
delimiter: Some("/".to_string()),
attributes: vec![Attribute {
attr: AttributeEnum::Extension,
extension: Some("CreatedByBichon".into()),
}],
exists: 0,
unseen: None,
uid_next: None,
uid_validity: None,
};
let mailbox_id = mailbox.id;
// Upsert the mailbox, creating it if it doesn't exist
MailBox::batch_upsert(&[mailbox]).await?;
mailbox_id
},
};
let fields = SchemaTools::eml_fields();
let account_id = account.id;
let mut success_count = 0;
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
let total = request.emls.len();
for (index, eml_base64) in request.emls.into_iter().enumerate() {
// 1. Decode Base64
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
Ok(bytes) => bytes,
Err(e) => {
let error_msg =
format!("Failed to decode base64 EML at index {}: {:?}", index, e);
tracing::error!("{}", error_msg);
failed_details.push(FailedEmlDetail {
index,
error_message: error_msg,
});
continue;
}
};
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
Ok(env) => env,
Err(e) => {
let error_msg = format!(
"Failed to extract envelope from EML at index {}: {:?}",
index, e
);
tracing::error!("{}", error_msg);
failed_details.push(FailedEmlDetail {
index,
error_message: error_msg,
});
continue;
}
};
ENVELOPE_INDEX_MANAGER
.add_document(envelope.id, envelope.to_document(mailbox_id).unwrap())
.await;
EML_INDEX_MANAGER
.add_document(
envelope.id,
doc!(
fields.f_id => envelope.id,
fields.f_account_id => account_id,
fields.f_mailbox_id => mailbox_id,
fields.f_eml => decoded
),
)
.await;
success_count += 1;
}
let failed_count = failed_details.len();
Ok(BatchEmlResult {
total,
success: success_count,
failed: failed_count,
failed_details, // Return the list of failure details
})
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{path::PathBuf, time::Duration};
use chrono::Utc;
@@ -145,20 +144,20 @@ async fn test2() {
let delete_term3 = Term::from_field_u64(a, 3u64);
let operations = vec![
//UserOperation::Delete(delete_term1),
UserOperation::Delete(delete_term1),
UserOperation::Add(doc!(
a => 1u64,
b => "test1"
b => "v1"
)),
//UserOperation::Delete(delete_term2),
UserOperation::Delete(delete_term2),
UserOperation::Add(doc!(
a => 2u64,
b => "test1"
b => "v1"
)),
//UserOperation::Delete(delete_term3),
UserOperation::Delete(delete_term3),
UserOperation::Add(doc!(
a => 3u64,
b => "test1"
b => "v1"
)),
];
@@ -166,13 +165,10 @@ async fn test2() {
index_writer.commit().unwrap();
let reader = index.reader().unwrap();
let searcher = reader.searcher();
let tq = TermQuery::new(Term::from_field_u64(a, 3), IndexRecordOption::Basic);
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
assert!(docs.first().is_some());
if let Some((_, doc_address)) = docs.first() {
let old_doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
@@ -181,12 +177,14 @@ async fn test2() {
if field == a {
new_doc.add_field_value(a, value);
}
if field == b {
assert_eq!(Some("v1"), value.as_str())
}
}
new_doc.add_text(b, "test2");
new_doc.add_text(b, "v2");
let delete_term = Term::from_field_u64(a, 3);
index_writer.delete_term(delete_term);
index_writer.commit().unwrap();
index_writer.add_document(new_doc).unwrap();
index_writer.commit().unwrap();
}
@@ -194,17 +192,14 @@ async fn test2() {
reader.reload().unwrap();
let searcher = reader.searcher();
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
assert!(docs.first().is_some());
if let Some((_, doc_address)) = docs.first() {
let doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
for (field, value) in doc.field_values() {
if field == b {
let value = value.as_str();
println!("{:#?}", value);
assert_eq!(Some("v2"), value.as_str())
}
}
} else {
println!("not found")
}
let delete_term = Term::from_field_u64(a, 3);
@@ -214,7 +209,7 @@ async fn test2() {
reader.reload().unwrap();
let searcher = reader.searcher();
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
assert!(docs.first().is_none());
if let Some((_, doc_address)) = docs.first() {
let doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
for (field, value) in doc.field_values() {
@@ -223,7 +218,5 @@ async fn test2() {
println!("{:#?}", value);
}
}
} else {
println!("not found")
}
}

View File

@@ -27,7 +27,7 @@ use crate::raise_error;
use async_imap::types::Name;
pub async fn get_account_mailboxes(account_id: u64, remote: bool) -> BichonResult<Vec<MailBox>> {
let account = AccountModel::check_account_active(account_id).await?;
let account = AccountModel::check_account_exists(account_id).await?;
if remote {
if matches!(account.account_type, AccountType::IMAP) {
request_imap_all_mailbox_list(account_id).await

View File

@@ -68,7 +68,7 @@ pub async fn retrieve_email_content(
account_id: u64,
id: u64,
) -> BichonResult<FullMessageContent> {
AccountModel::check_account_active(account_id).await?;
AccountModel::check_account_exists(account_id).await?;
let eml = EML_INDEX_MANAGER
.get(account_id, id)
.await?

View File

@@ -33,7 +33,7 @@ pub async fn list_messages_impl(
page: u64,
page_size: u64,
) -> BichonResult<DataPage<Envelope>> {
AccountModel::check_account_active(account_id).await?;
AccountModel::check_account_exists(account_id).await?;
validate_pagination_params(page, page_size)?;
ENVELOPE_INDEX_MANAGER
.list_mailbox_envelopes(account_id, mailbox_id, page, page_size, true)
@@ -62,7 +62,7 @@ pub async fn get_thread_messages(
page: u64,
page_size: u64,
) -> BichonResult<DataPage<Envelope>> {
AccountModel::check_account_active(account_id).await?;
AccountModel::check_account_exists(account_id).await?;
ENVELOPE_INDEX_MANAGER
.list_thread_envelopes(account_id, thread_id, page, page_size, true)
.await

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/>.
pub mod account;
pub mod autoconfig;
pub mod cache;
@@ -27,6 +26,7 @@ pub mod database;
pub mod envelope;
pub mod error;
pub mod imap;
pub mod import;
pub mod indexer;
pub mod logger;
pub mod mailbox;

View File

@@ -16,14 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::modules::account::migration::AccountModel;
use crate::modules::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use crate::modules::account::state::AccountRunningState;
use crate::modules::account::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::common::paginated::paginate_vec;
use crate::modules::error::code::ErrorCode;
@@ -114,11 +113,7 @@ impl AccountApi {
}
/// List accounts with optional pagination parameters
#[oai(
path = "/accounts",
method = "get",
operation_id = "list_accounts"
)]
#[oai(path = "/accounts", method = "get", operation_id = "list_accounts")]
async fn list_accounts(
&self,
/// Optional. The page number to retrieve (starting from 1).
@@ -172,6 +167,7 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<Json<AccountRunningState>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
raise_error!(

View File

@@ -0,0 +1,49 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::import::BatchEmlResult;
use crate::modules::import::{BatchEmlRequest, ImportEmls};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct ImportApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Import")]
impl ImportApi {
/// Batch import one or more EML files into a specified account and mail folder.
///
/// This endpoint accepts a JSON payload containing:
/// - `account_id`: the target account to import emails into
/// - `mail_folder`: the mailbox/folder name
/// - `emls`: a list of base64-encoded .eml files
///
/// Returns a summary of the import result, including total processed, successful, and failed emails.
#[oai(path = "/import", method = "post", operation_id = "do_batch_import")]
async fn do_batch_import(
&self,
/// JSON payload with account info and EML files to import
payload: Json<BatchEmlRequest>,
context: ClientContext,
) -> ApiResult<Json<BatchEmlResult>> {
context.require_root()?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}

View File

@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::indexer::envelope::Envelope;
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
@@ -156,6 +156,7 @@ impl MessageApi {
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
let id = id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, id).await?;
@@ -180,6 +181,7 @@ impl MessageApi {
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
let email_id = id.0;
let name = name.0.trim();

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 access_token::AccessTokenApi;
use account::AccountApi;
use auto_config::AutoConfigApi;
@@ -26,11 +25,12 @@ use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use system::SystemApi;
use crate::bichon_version;
use crate::{bichon_version, modules::rest::api::import::ImportApi};
pub mod access_token;
pub mod account;
pub mod auto_config;
pub mod import;
pub mod mailbox;
pub mod message;
pub mod oauth2;
@@ -45,6 +45,7 @@ pub enum ApiTags {
OAuth2,
Message,
System,
Import,
}
type RustMailOpenApi = (
@@ -55,6 +56,7 @@ type RustMailOpenApi = (
MailBoxApi,
OAuth2Api,
MessageApi,
ImportApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
@@ -67,6 +69,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
MailBoxApi,
OAuth2Api,
MessageApi,
ImportApi,
),
"BichonApi",
bichon_version!(),

View File

@@ -195,19 +195,19 @@ impl OAuth2Api {
/// Configures an external OAuth2 token for a specified account.
///
/// This endpoint allows two usage modes:
/// 1. If only an `access_token` is provided, RustMailer will store it directly.
/// - In this mode, RustMailer **cannot refresh** the token, since it has no
/// 1. If only an `access_token` is provided, Bichon will store it directly.
/// - In this mode, Bichon **cannot refresh** the token, since it has no
/// associated OAuth2 configuration or refresh token.
/// - The caller is responsible for periodically updating the access token
/// by calling this endpoint again.
/// 2. If both `oauth2_id` and `refresh_token` are provided, it means the external
/// OAuth2 authorization flow has been completed outside RustMailer.
/// OAuth2 authorization flow has been completed outside Bichon.
/// - Since the OAuth2 configuration (including client_id and client_secret)
/// is already stored in RustMailer, the service can use the refresh token
/// is already stored in Bichon, the service can use the refresh token
/// to obtain new access tokens automatically.
///
/// Note: The `oauth2_id` must reference a valid OAuth2 configuration
/// already created in RustMailer.
/// already created in Bichon.
#[oai(
path = "/store-external-oauth2-token/:account_id",
method = "post",

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::common::error::ErrorCapture;
use crate::modules::common::log::Tracing;
use crate::modules::common::tls::rustls_config;
@@ -33,13 +32,14 @@ use crate::modules::common::timeout::{Timeout, TIMEOUT_HEADER};
use crate::raise_error;
use api::create_openapi_service;
use assets::FrontEndAssets;
use http::HeaderValue;
use http::{HeaderValue, Method};
use poem::endpoint::EmbeddedFilesEndpoint;
use poem::listener::{Listener, TcpListener};
use poem::middleware::{CatchPanic, Compression, SetHeader};
use poem::{endpoint::EmbeddedFileEndpoint, middleware::Cors, EndpointExt, Route, Server};
use poem::{get, post};
use public::oauth2::oauth2_callback;
use std::collections::HashSet;
use std::time::Duration;
pub mod api;
@@ -62,7 +62,7 @@ pub async fn start_http_server() -> BichonResult<()> {
};
let api_service = create_openapi_service()
.summary("A self-hosted IMAP/SMTP middleware designed for developers");
.summary("A lightweight, high-performance Rust email archiver with WebUI");
let swagger = api_service.swagger_ui();
let redoc = api_service.redoc();
@@ -78,10 +78,35 @@ pub async fn start_http_server() -> BichonResult<()> {
.with(Timeout)
.with(Tracing);
let mut cors_origins = SETTINGS.bichon_cors_origins.clone();
if cors_origins.is_empty() {
cors_origins = ["*".to_string()].into_iter().collect();
}
let cors_origins: Option<HashSet<String>> =
SETTINGS.bichon_cors_origins.clone();
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
let cors = Cors::new()
.allow_origins_fn(move |origin| {
tracing::debug!("CORS: Incoming Origin = {:?}", origin);
tracing::debug!("CORS: Configured origins = {:?}", cors_origins);
if cors_origins.is_empty() {
tracing::debug!("CORS: No origins configured, allowing all");
return true;
}
cors_origins.iter().any(|o| o == origin)
})
//.allow_origins(cors_origins)
.allow_credentials(true)
.allow_methods(&[
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
Method::HEAD,
Method::PATCH,
])
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
.expose_headers(vec!["Accept"])
.max_age(SETTINGS.bichon_cors_max_age);
let cache_static = || {
SetHeader::new().overriding(
@@ -90,14 +115,6 @@ pub async fn start_http_server() -> BichonResult<()> {
)
};
let cors = Cors::new()
.allow_origins(cors_origins)
.allow_credentials(true)
.allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"])
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
.expose_headers(vec!["Accept"])
.max_age(SETTINGS.bichon_cors_max_age);
let route = Route::new()
.nest("/api-docs/swagger", swagger)
.nest("/api-docs/redoc", redoc)
@@ -122,7 +139,7 @@ pub async fn start_http_server() -> BichonResult<()> {
.with(CatchPanic::new());
let server = Server::new(listener)
.name("RustMailer API Service")
.name("Bichon Service")
.idle_timeout(Duration::from_secs(60))
.run_with_graceful_shutdown(
route.catch_all_error(error_handler),
@@ -130,7 +147,7 @@ pub async fn start_http_server() -> BichonResult<()> {
Some(Duration::from_secs(5)),
);
println!(
"RustMailer API Service is now running on port {}.",
"Bichon Service is now running on port {}.",
SETTINGS.bichon_http_port
);
server

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 clap::{builder::ValueParser, Parser, ValueEnum};
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
@@ -77,7 +76,6 @@ pub struct Settings {
/// CORS allowed origins (default: "*")
#[clap(
long,
default_value = "http://localhost:5173, http://localhost:15630, http://192.168.3.2:15630, *",
env,
help = "Set the allowed CORS origins (comma-separated list, e.g., \"https://example.com, https://another.com\")",
value_parser = ValueParser::new(|s: &str| -> Result<HashSet<String>, String> {
@@ -88,7 +86,7 @@ pub struct Settings {
Ok(set)
})
)]
pub bichon_cors_origins: HashSet<String>,
pub bichon_cors_origins: Option<HashSet<String>>,
/// CORS max age in seconds (default: 86400)
#[clap(

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::settings::proxy::Proxy;
use crate::modules::utils::tls::establish_tls_stream;
@@ -56,17 +55,19 @@ pub(crate) async fn establish_tcp_connection_with_timeout(
Ok(Box::pin(timeout_stream))
}
pub(crate) async fn establish_tls_connection(
pub async fn establish_tls_connection(
address: SocketAddr,
server_hostname: &str,
alpn_protocols: &[&str],
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<impl SessionStream> {
// Establish the TCP connection with timeout
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
// Wrap the TCP stream with TLS encryption
let tls_stream = establish_tls_stream(server_hostname, alpn_protocols, tcp_stream).await?;
let tls_stream =
establish_tls_stream(server_hostname, alpn_protocols, tcp_stream, dangerous).await?;
// Return the TLS stream wrapped in a SessionStream
Ok(tls_stream)

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
@@ -24,38 +23,44 @@ use crate::{
},
raise_error,
};
use rustls::RootCertStore;
use rustls::{
client::danger::{ServerCertVerified, ServerCertVerifier},
RootCertStore,
};
use std::sync::Arc;
pub async fn establish_tls_stream(
server_hostname: &str,
alpn_protocols: &[&str],
stream: impl SessionStream + 'static,
dangerous: bool,
) -> BichonResult<impl SessionStream> {
let tls_stream = establish_rustls_stream(server_hostname, alpn_protocols, stream).await?;
let tls_stream =
establish_rustls_stream(server_hostname, alpn_protocols, stream, dangerous).await?;
let boxed_stream: Box<dyn SessionStream> = Box::new(tls_stream);
Ok(boxed_stream)
}
pub async fn establish_rustls_stream(
async fn establish_rustls_stream(
server_hostname: &str,
alpn_protocols: &[&str],
stream: impl SessionStream,
dangerous: bool,
) -> BichonResult<impl SessionStream> {
// Create a root certificate store and add default trusted roots
let root_store = RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
};
// Configure the Rustls client with the root certs and no client authentication
let mut config = rustls::ClientConfig::builder()
//builder_with_provider(
// rustls::crypto::ring::default_provider().into(),
// )
// .with_protocol_versions(&[&rustls::version::TLS13])
// .unwrap()
.with_root_certificates(root_store)
.with_no_client_auth();
let mut config = if dangerous {
rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipCertVerification))
.with_no_client_auth()
} else {
rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth()
};
// Set the ALPN protocols
config.alpn_protocols = alpn_protocols
@@ -76,3 +81,56 @@ pub async fn establish_rustls_stream(
Ok(tls_stream)
}
#[derive(Debug)]
struct SkipCertVerification;
impl ServerCertVerifier for SkipCertVerification {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
// Always return a valid certificate verification result
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}

View File

@@ -13,7 +13,11 @@
"knip": "knip"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@hookform/resolvers": "^3.9.1",
"@mui/material": "^7.3.5",
"@mui/x-tree-view": "^8.19.0",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
@@ -37,6 +41,7 @@
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-visually-hidden": "^1.1.0",
"@react-spring/web": "^10.0.3",
"@tabler/icons-react": "^3.24.0",
"@tanstack/react-query": "^5.62.3",
"@tanstack/react-router": "^1.86.1",
@@ -50,6 +55,7 @@
"date-fns": "^3.6.0",
"file-saver": "^2.0.5",
"handlebars": "^4.7.8",
"i18next": "^25.6.3",
"js-cookie": "^3.0.5",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
@@ -57,6 +63,7 @@
"react-day-picker": "8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.0",
"react-i18next": "^16.3.5",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.14.1",

656
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,7 @@ export interface OAuth2Tokens {
access_token: string;
account_id: string;
created_at: number;
oauth2_name: string;
oauth2_id: number;
refresh_token: string;
updated_at: number;
}

View File

@@ -54,6 +54,8 @@ export interface DashboardStats {
with_attachment_count: number; // Emails with attachments
without_attachment_count: number; // Emails without attachments
top_largest_emails: LargestEmail[]; // Top 10 largest emails
system_version: string, //The semantic version string of the currently running backend service
commit_hash: string //Git commit hash used to build this system version
}
export interface TimeBucket {

View File

@@ -21,16 +21,18 @@ import { Card } from "@/components/ui/card";
import { FixedHeader } from "./layout/fixed-header";
import { Main } from "./layout/main";
import Logo from '@/assets/logo.svg'
const docsOptions = [
{ name: "Swagger UI", path: "/api-docs/swagger" },
{ name: "ReDoc", path: "/api-docs/redoc" },
{ name: "OpenAPI Explorer", path: "/api-docs/explorer" },
{ name: "Scalar", path: "/api-docs/scalar" },
{ name: "Download Spec YAML", path: "/api-docs/spec.yaml" }
];
import { useTranslation } from 'react-i18next'
export default function APIDocs() {
const { t } = useTranslation()
const docsOptions = [
{ name: t('apiDocs.swaggerUI'), path: "/api-docs/swagger" },
{ name: t('apiDocs.reDoc'), path: "/api-docs/redoc" },
{ name: t('apiDocs.openAPIExplorer'), path: "/api-docs/explorer" },
{ name: t('apiDocs.scalar'), path: "/api-docs/scalar" },
{ name: t('apiDocs.downloadSpecYAML'), path: "/api-docs/spec.yaml" }
];
const handleCardClick = (path: string) => {
// Open in new tab
window.open(path, '_blank', 'noopener,noreferrer');
@@ -43,9 +45,9 @@ export default function APIDocs() {
<Main>
<div className='mb-2 flex items-center justify-between space-y-2 flex-wrap gap-x-4'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>API Documentation</h2>
<h2 className='text-2xl font-bold tracking-tight'>{t('navigation.apiDocs')}</h2>
<p className='text-muted-foreground'>
Choose your preferred API documentation type
{t('apiDocs.choosePreferredType')}
</p>
</div>
</div>
@@ -62,7 +64,7 @@ export default function APIDocs() {
<img
src={Logo}
className="max-h-[66px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="RustMailer Logo"
alt="Bichon Logo"
/>
<h3 className="text-sm font-medium">{option.name}</h3>
</div>

View File

@@ -35,13 +35,16 @@ import {
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { sidebarData } from './layout/data/sidebar-data'
import { useSidebarData } from './layout/data/sidebar-data'
import { ScrollArea } from './ui/scroll-area'
import { useTranslation } from 'react-i18next'
export function CommandMenu() {
const navigate = useNavigate()
const { setTheme } = useTheme()
const { open, setOpen } = useSearch()
const sidebarData = useSidebarData()
const { t } = useTranslation()
const runCommand = React.useCallback(
(command: () => unknown) => {
@@ -53,10 +56,10 @@ export function CommandMenu() {
return (
<CommandDialog modal open={open} onOpenChange={setOpen}>
<CommandInput placeholder='Type a command or search...' />
<CommandInput placeholder={t('command.typeCommandOrSearch')} />
<CommandList>
<ScrollArea type='hover' className='h-72 pr-1'>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty>{t('command.noResultsFound')}</CommandEmpty>
{sidebarData.navGroups.map((group) => (
<CommandGroup key={group.title} heading={group.title}>
{group.items.map((navItem, i) => {
@@ -94,13 +97,13 @@ export function CommandMenu() {
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup heading='Theme'>
<CommandGroup heading={t('command.theme')}>
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
<IconSun /> <span>Light</span>
<IconSun /> <span>{t('command.light')}</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
<IconMoon className='scale-90' />
<span>Dark</span>
<span>{t('command.dark')}</span>
</CommandItem>
</CommandGroup>
</ScrollArea>

View File

@@ -28,6 +28,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useTranslation } from 'react-i18next'
interface ConfirmDialogProps {
open: boolean
@@ -45,6 +46,7 @@ interface ConfirmDialogProps {
}
export function ConfirmDialog(props: ConfirmDialogProps) {
const { t } = useTranslation()
const {
title,
desc,
@@ -70,14 +72,14 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
{children}
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
{cancelBtnText ?? 'Cancel'}
{cancelBtnText ?? t('common.cancel')}
</AlertDialogCancel>
<Button
variant={destructive ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={disabled || isLoading}
>
{confirmText ?? 'Continue'}
{confirmText ?? t('dialogs.continue')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -0,0 +1,77 @@
//
// 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 { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Languages } from 'lucide-react'
const LANGUAGES = [
{ code: 'ar', label: 'العربية' },
{ code: 'da', label: 'Dansk' },
{ code: 'de', label: 'Deutsch' },
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
{ code: 'fi', label: 'Suomi' },
{ code: 'fr', label: 'Français' },
{ code: 'it', label: 'Italiano' },
{ code: 'jp', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'nl', label: 'Nederlands' },
{ code: 'no', label: 'Norsk' },
{ code: 'pt', label: 'Português' },
{ code: 'ru', label: 'Русский' },
{ code: 'sv', label: 'Svenska' },
{ code: 'zh', label: '中文' },
{ code: 'zh-tw', label: '繁體中文' },
]
export function LanguageSwitch() {
const { i18n } = useTranslation()
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Languages className="h-4 w-4" />
<span className="sr-only">Change language</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{LANGUAGES.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => changeLanguage(lang.code)}
>
{lang.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -26,10 +26,11 @@ import {
} from '@/components/ui/sidebar'
import { NavGroup } from '@/components/layout/nav-group'
import Logo from '@/assets/logo.svg'
import { sidebarData } from './data/sidebar-data'
import { useSidebarData } from './data/sidebar-data'
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { open } = useSidebar();
const sidebarData = useSidebarData();
return (
<Sidebar collapsible='icon' variant='sidebar' {...props}>
<SidebarHeader>

View File

@@ -25,68 +25,73 @@ import {
} from '@tabler/icons-react'
import { IdCard, Inbox, Mailbox, Search } from 'lucide-react'
import { type SidebarData } from '../types'
import { useTranslation } from 'react-i18next'
export const sidebarData: SidebarData = {
navGroups: [
{
title: 'General',
items: [
{
title: 'Dashboard',
url: '/',
icon: IconLayoutDashboard,
}
],
},
{
title: 'Accounts',
items: [
{
title: 'Accounts',
url: '/accounts',
icon: Inbox,
},
{
title: 'MailBox',
url: '/mailboxes',
icon: Mailbox,
},
{
title: 'Search',
url: '/search',
icon: Search,
}
],
},
{
title: 'Auth',
items: [
{
title: 'OAuth2',
url: '/oauth2',
icon: IdCard,
},
{
title: 'Access Tokens',
url: '/access-tokens',
icon: IconLockAccess,
}
]
},
{
title: 'Other',
items: [
{
title: 'Settings',
url: '/settings',
icon: IconSettings,
},
{
title: 'API Documentation',
url: '/api-docs',
icon: IconHelp,
},
],
},
],
export function useSidebarData(): SidebarData {
const { t } = useTranslation()
return {
navGroups: [
{
title: t('navigation.general'),
items: [
{
title: t('navigation.dashboard'),
url: '/',
icon: IconLayoutDashboard,
}
],
},
{
title: t('navigation.accounts'),
items: [
{
title: t('navigation.accounts'),
url: '/accounts',
icon: Inbox,
},
{
title: t('navigation.mailbox'),
url: '/mailboxes',
icon: Mailbox,
},
{
title: t('common.search'),
url: '/search',
icon: Search,
}
],
},
{
title: t('navigation.auth'),
items: [
{
title: t('navigation.oauth2'),
url: '/oauth2',
icon: IdCard,
},
{
title: t('navigation.accessTokens'),
url: '/access-tokens',
icon: IconLockAccess,
}
]
},
{
title: t('navigation.other'),
items: [
{
title: t('navigation.settings'),
url: '/settings',
icon: IconSettings,
},
{
title: t('navigation.apiDocs'),
url: '/api-docs',
icon: IconHelp,
},
],
},
],
}
}

View File

@@ -22,6 +22,7 @@ import { ProfileDropdown } from "../profile-dropdown";
import { Header } from "./header";
import { NotificationPopover } from "./notification";
import { GithubLinkButton } from "./github";
import { LanguageSwitch } from "../language-switch";
export const FixedHeader = () => {
return (
@@ -30,6 +31,7 @@ export const FixedHeader = () => {
<div className='ml-auto flex items-center space-x-4'>
<NotificationPopover />
<GithubLinkButton />
<LanguageSwitch />
<ThemeSwitch />
<ProfileDropdown />
</div>

View File

@@ -45,6 +45,7 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { useTranslation } from "react-i18next";
/**
* Variants for the multi-select component to handle different styles.
@@ -75,7 +76,7 @@ const multiSelectVariants = cva(
*/
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
VariantProps<typeof multiSelectVariants> {
/**
* An array of option objects to be displayed in the multi-select component.
* Each option object has a label, value, and an optional icon.
@@ -156,6 +157,7 @@ export const MultiSelect = React.forwardRef<
},
ref
) => {
const { t } = useTranslation()
const [selectedValues, setSelectedValues] =
React.useState<string[]>(defaultValue);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
@@ -308,7 +310,7 @@ export const MultiSelect = React.forwardRef<
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty>{t('common.table.noResults')}</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"

View File

@@ -29,6 +29,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface PaginationProps {
totalItems: number
@@ -47,6 +48,7 @@ export function EnvelopeListPagination({
setPageIndex,
setPageSize,
}: PaginationProps) {
const { t } = useTranslation()
const pageCount = Math.ceil(totalItems / pageSize)
const handlePageSizeChange = (value: string) => {
@@ -67,11 +69,11 @@ export function EnvelopeListPagination({
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'>
{totalItems} total items
{totalItems} {t("table.results")}
</div>
<div className='flex items-center sm:space-x-6 lg:space-x-4'>
<div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
<Select
value={`${pageSize}`}
onValueChange={handlePageSizeChange}
@@ -80,7 +82,7 @@ export function EnvelopeListPagination({
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((size) => (
{[10, 20, 30, 40, 50, 100].map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
@@ -89,7 +91,7 @@ export function EnvelopeListPagination({
</Select>
</div>
<div className='flex items-center justify-center text-sm font-medium'>
Page {pageIndex + 1} of {pageCount}
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button
@@ -98,7 +100,7 @@ export function EnvelopeListPagination({
onClick={goToPreviousPage}
disabled={pageIndex === 0}
>
<span className='sr-only'>Go to previous page</span>
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -107,7 +109,7 @@ export function EnvelopeListPagination({
onClick={goToNextPage}
disabled={!hasNextPage()}
>
<span className='sr-only'>Go to next page</span>
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
</div>

View File

@@ -30,9 +30,11 @@ import { LogoutConfirmDialog } from '@/features/auth/sign-in/components/logout';
import { resetAccessToken } from '@/stores/authStore';
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
export function ProfileDropdown() {
const navigate = useNavigate()
const { t } = useTranslation()
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
const handleLogout = () => {
@@ -52,7 +54,7 @@ export function ProfileDropdown() {
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<DropdownMenuItem onClick={() => setIsLogoutDialogOpen(true)}>
Log out
{t('auth.logout')}
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,529 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronRight } from 'lucide-react'
import { cva } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const treeVariants = cva(
'group hover:before:opacity-100 before:absolute before:rounded-lg before:left-0 px-2 before:w-full before:opacity-0 before:bg-accent/70 before:h-[2rem] before:-z-10'
)
const selectedTreeVariants = cva(
'before:opacity-100 before:bg-accent/70 text-accent-foreground'
)
interface TreeDataItem {
id: string
name: string
icon?: React.ComponentType<{ className?: string }>
openIcon?: React.ComponentType<{ className?: string }>
children?: TreeDataItem[]
badge?: React.ReactNode,
attributes?: React.ReactNode,
onClick?: () => void
}
type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
data: TreeDataItem[] | TreeDataItem
onSelectChange?: (item: TreeDataItem | undefined) => void
onSelectItemsChange?: (items: TreeDataItem[]) => void
expandAll?: boolean
multiple?: boolean
clickRowToSelect?: boolean
initialSelectedItemIds?: string[]
defaultNodeIcon?: React.ComponentType<{ className?: string }>
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeView = React.forwardRef<HTMLDivElement, TreeProps>(
(
{
data,
onSelectChange,
onSelectItemsChange,
expandAll,
defaultLeafIcon,
defaultNodeIcon,
clickRowToSelect = true,
className,
multiple,
initialSelectedItemIds = [],
...props
},
ref
) => {
const [selectedItemIds, setSelectedItemIds] = React.useState<Set<string>>(
new Set(initialSelectedItemIds)
);
const callbacksRef = React.useRef({
onSelectChange,
onSelectItemsChange
});
React.useEffect(() => {
callbacksRef.current = {
onSelectChange,
onSelectItemsChange
};
}, [onSelectChange, onSelectItemsChange]);
const handleSelectChange = React.useCallback(
(item: TreeDataItem | undefined) => {
if (!item) return;
setSelectedItemIds(prev => {
const newSet = new Set(prev);
if (newSet.has(item.id)) {
newSet.delete(item.id);
} else {
if (!multiple) {
newSet.clear();
}
newSet.add(item.id);
}
setTimeout(() => {
if (callbacksRef.current.onSelectChange) {
callbacksRef.current.onSelectChange(newSet.has(item.id) ? item : undefined);
}
if (callbacksRef.current.onSelectItemsChange) {
const selectedItems = Array.from(newSet)
.map(id => findItemById(data, id))
.filter(Boolean) as TreeDataItem[];
callbacksRef.current.onSelectItemsChange(selectedItems);
}
}, 0);
return newSet;
});
},
[multiple, onSelectChange, onSelectItemsChange, data]
);
const expandedItemIds = React.useMemo(() => {
if (!initialSelectedItemIds || initialSelectedItemIds.length === 0) {
return [] as string[]
}
const ids: string[] = []
function walkTreeItems(
items: TreeDataItem[] | TreeDataItem,
targetIds: string[]
) {
if (Array.isArray(items)) {
for (let i = 0; i < items.length; i++) {
ids.push(items[i]!.id)
if (walkTreeItems(items[i]!, targetIds) && !expandAll) {
return true
}
if (!expandAll) ids.pop()
}
} else if (!expandAll && targetIds.includes(items.id)) {
return true
} else if (items.children) {
return walkTreeItems(items.children, targetIds)
}
}
walkTreeItems(data, initialSelectedItemIds)
return ids
}, [data, expandAll, initialSelectedItemIds])
return (
<div className={cn('overflow-hidden relative p-2', className)}>
<TreeItem
data={data}
ref={ref}
clickRowToSelect={clickRowToSelect}
selectedItemIds={selectedItemIds}
handleSelectChange={handleSelectChange}
expandedItemIds={expandedItemIds}
defaultLeafIcon={defaultLeafIcon}
defaultNodeIcon={defaultNodeIcon}
{...props}
/>
</div>
)
}
)
TreeView.displayName = 'TreeView'
// Helper function to find item by ID in tree
function findItemById(items: TreeDataItem[] | TreeDataItem, id: string): TreeDataItem | undefined {
if (Array.isArray(items)) {
for (const item of items) {
const found = findItemById(item, id);
if (found) return found;
}
} else {
if (items.id === id) return items;
if (items.children) {
return findItemById(items.children, id);
}
}
return undefined;
}
type TreeItemProps = TreeProps & {
selectedItemIds: Set<string>
handleSelectChange: (item: TreeDataItem | undefined) => void
expandedItemIds: string[]
clickRowToSelect?: boolean
defaultNodeIcon?: React.ComponentType<{ className?: string }>
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
(
{
className,
data,
selectedItemIds,
handleSelectChange,
clickRowToSelect,
expandedItemIds,
defaultNodeIcon,
defaultLeafIcon,
...props
},
ref
) => {
if (!Array.isArray(data)) {
data = [data]
}
return (
<div ref={ref} role="tree" className={className} {...props}>
<ul>
{data.map((item) => (
<li key={item.id}>
{item.children ? (
<TreeNode
item={item}
selectedItemIds={selectedItemIds}
expandedItemIds={expandedItemIds}
clickRowToSelect={clickRowToSelect}
handleSelectChange={handleSelectChange}
defaultNodeIcon={defaultNodeIcon}
defaultLeafIcon={defaultLeafIcon}
/>
) : (
<TreeLeaf
item={item}
clickRowToSelect={clickRowToSelect}
selectedItemIds={selectedItemIds}
handleSelectChange={handleSelectChange}
defaultLeafIcon={defaultLeafIcon}
/>
)}
</li>
))}
</ul>
</div>
)
}
)
TreeItem.displayName = 'TreeItem'
interface TreeNodeProps {
item: TreeDataItem
handleSelectChange: (item: TreeDataItem | undefined) => void
expandedItemIds: string[]
clickRowToSelect?: boolean
selectedItemIds: Set<string>
defaultNodeIcon?: React.ComponentType<{ className?: string }>
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeNode = ({
item,
handleSelectChange,
expandedItemIds,
clickRowToSelect,
selectedItemIds,
defaultNodeIcon,
defaultLeafIcon
}: TreeNodeProps) => {
const [value, setValue] = React.useState(
expandedItemIds.includes(item.id) ? [item.id] : []
)
const isSelected = selectedItemIds.has(item.id);
return (
<AccordionPrimitive.Root
type="multiple"
value={value}
onValueChange={(s) => setValue(s)}
>
<AccordionPrimitive.Item value={item.id}>
<AccordionTrigger
className={cn(
"flex items-center w-full py-2",
treeVariants(),
isSelected && selectedTreeVariants()
)}
onClick={(e) => {
e.stopPropagation();
if (clickRowToSelect) {
handleSelectChange(item);
}
item.onClick?.();
}}
>
<div className="flex items-center min-w-0 flex-shrink-0">
<TreeIcon
item={item}
isSelected={isSelected}
isOpen={value.includes(item.id)}
default={defaultNodeIcon}
onCheck={() => { handleSelectChange(item) }}
/>
<span className="ml-2 text-sm truncate">
{item.name}
</span>
</div>
{item.attributes && (
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
{item.attributes}
</span>
)}
{item.badge && (
<TreeBadge isSelected={isSelected}>
{item.badge}
</TreeBadge>
)}
</AccordionTrigger>
<AccordionContent className="ml-4 pl-1 border-l">
<TreeItem
data={item.children ? item.children : item}
selectedItemIds={selectedItemIds}
clickRowToSelect={clickRowToSelect}
handleSelectChange={handleSelectChange}
expandedItemIds={expandedItemIds}
defaultLeafIcon={defaultLeafIcon}
defaultNodeIcon={defaultNodeIcon}
/>
</AccordionContent>
</AccordionPrimitive.Item>
</AccordionPrimitive.Root>
)
}
// function hasSelectedChildrenRecursive(item: TreeDataItem, selectedItemIds: Set<string>): boolean {
// if (!item.children) return false;
// return item.children.some(child =>
// selectedItemIds.has(child.id) ||
// (child.children && hasSelectedChildrenRecursive(child, selectedItemIds)));
// }
interface TreeLeafProps extends React.HTMLAttributes<HTMLDivElement> {
item: TreeDataItem
selectedItemIds: Set<string>
clickRowToSelect?: boolean
handleSelectChange: (item: TreeDataItem | undefined) => void
defaultLeafIcon?: React.ComponentType<{ className?: string }>
}
const TreeLeaf = React.forwardRef<HTMLDivElement, TreeLeafProps>(
(
{
className,
item,
clickRowToSelect,
selectedItemIds,
handleSelectChange,
defaultLeafIcon,
...props
},
ref
) => {
return (
<div
ref={ref}
className={cn(
"ml-5 flex items-center py-2 cursor-pointer before:right-1",
treeVariants(),
className,
selectedItemIds.has(item.id) && selectedTreeVariants()
)}
onClick={(e) => {
e.stopPropagation();
if (clickRowToSelect) {
handleSelectChange(item);
}
item.onClick?.();
}}
{...props}
>
<div className="flex items-center min-w-0 flex-shrink-0">
<TreeIcon
item={item}
isSelected={selectedItemIds.has(item.id)}
default={defaultLeafIcon}
onCheck={() => { handleSelectChange(item) }}
/>
<span className="ml-2 text-sm truncate">
{item.name}
</span>
</div>
{item.attributes && (
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
{item.attributes}
</span>
)}
{item.badge && (
<TreeBadge isSelected={selectedItemIds.has(item.id)}>
{item.badge}
</TreeBadge>
)}
</div>
)
}
)
TreeLeaf.displayName = 'TreeLeaf'
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header>
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 w-full items-center py-2 transition-all first:[&[data-state=open]>svg]:rotate-90',
className
)}
{...props}
onClick={(e) => {
e.stopPropagation()
if (props.onClick) {
props.onClick(e)
}
}}
>
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200 text-accent-foreground/50 mr-1" />
{children}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
'overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
className
)}
{...props}
>
<div className="pb-1 pt-0">{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
interface TreeIconProps {
item: TreeDataItem;
isOpen?: boolean;
isSelected?: boolean;
default?: React.ComponentType<{ className?: string }>;
onCheck?: (checked: boolean) => void;
}
const TreeIcon = ({
item,
isOpen,
isSelected,
default: defaultIcon,
onCheck,
}: TreeIconProps) => {
let Icon = defaultIcon;
if (isOpen && item.openIcon) {
Icon = item.openIcon;
} else if (item.icon) {
Icon = item.icon;
}
return (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => onCheck?.(!isSelected)}
onClick={(e) => e.stopPropagation()}
className={cn(
"h-4 w-4 rounded border border-primary dark:border-white shadow transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isSelected
? "bg-black dark:bg-white text-primary-foreground"
: "bg-transparent",
"appearance-none cursor-pointer flex items-center justify-center relative",
"after:content-[''] after:w-1.5 after:h-2",
"after:border-r-2 after:border-b-2 after:rotate-45 after:mt-[-2px]",
isSelected
? "after:block after:border-white dark:after:border-black after:z-10"
: "after:hidden"
)}
/>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
</div>
);
};
interface TreeBadgeProps {
children: React.ReactNode
isSelected: boolean
showOnSelectedOnly?: boolean
}
const TreeBadge = ({
children,
isSelected,
showOnSelectedOnly = false
}: TreeBadgeProps) => {
return (
<div
className={cn(
showOnSelectedOnly
? isSelected
? 'block'
: 'hidden'
: 'block',
'absolute right-3 group-hover:block'
)}
>
{children}
</div>
)
}
export { TreeView, type TreeDataItem }

View File

@@ -44,6 +44,7 @@ import {
import { AccessToken } from '../data/schema'
import { DataTablePagination } from './data-table-pagination'
import { DataTableToolbar } from './data-table-toolbar'
import { useTranslation } from 'react-i18next'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -62,7 +63,7 @@ export function AccessTokensTable({ columns, data }: DataTableProps) {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [sorting, setSorting] = useState<SortingState>([])
const { t } = useTranslation()
const table = useReactTable({
data,
columns,
@@ -139,7 +140,7 @@ export function AccessTokensTable({ columns, data }: DataTableProps) {
colSpan={columns.length}
className='h-24 text-center'
>
No results.
{t('common.table.noResults')}
</TableCell>
</TableRow>
)}

View File

@@ -19,6 +19,7 @@
import { Table } from '@tanstack/react-table'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
interface DataTableToolbarProps<TData> {
table: Table<TData>
@@ -27,11 +28,12 @@ interface DataTableToolbarProps<TData> {
export function AccountDetailTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between'>
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
<Input
placeholder='Filter by email address or account ID...'
placeholder={t('accessTokens.filterByEmailAddressOrAccountId')}
value={(table.getState().globalFilter as string) ?? ''}
onChange={(event) => {
table.setGlobalFilter(event.target.value);

View File

@@ -22,11 +22,11 @@ import LongText from '@/components/long-text'
import { AccountInfo } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
export const columns: ColumnDef<AccountInfo>[] = [
export const getColumns = (t: (key: string) => string): ColumnDef<AccountInfo>[] => [
{
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Account_Id' />
<DataTableColumnHeader column={column} title={t('accessTokens.accountId')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.id}</LongText>
@@ -38,7 +38,7 @@ export const columns: ColumnDef<AccountInfo>[] = [
{
accessorKey: 'email',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Email' />
<DataTableColumnHeader column={column} title={t('accounts.email')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.getValue('email')}</LongText>

View File

@@ -29,7 +29,8 @@ import {
import { AccessToken } from '../data/schema'
import { Button } from '@/components/ui/button'
import { AccountsDetailTable } from './accounts-detail-table'
import { columns } from './accounts-detail-columns'
import { getColumns } from './accounts-detail-columns'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
@@ -38,6 +39,8 @@ interface Props {
}
export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const columns = getColumns(t)
return (
<Dialog
open={open}
@@ -47,9 +50,9 @@ export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
>
<DialogContent className='sm:max-w-4xl'>
<DialogHeader className='text-left'>
<DialogTitle>Accounts</DialogTitle>
<DialogTitle>{t('settings.accounts')}</DialogTitle>
<DialogDescription>
The list of accounts that can be queried using an access token.
{t('accessTokens.theListOfAccountsThatCanBeQueried')}
</DialogDescription>
</DialogHeader>
<div className="h-[33rem] overflow-x-auto overflow-y-auto">
@@ -57,7 +60,7 @@ export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -43,6 +43,7 @@ import {
import { AccountInfo } from '../data/schema'
import { DataTablePagination } from './data-table-pagination'
import { AccountDetailTableToolbar } from './account-detail-table-toolbar'
import { useTranslation } from 'react-i18next'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -57,6 +58,7 @@ interface DataTableProps {
}
export function AccountsDetailTable({ columns, data }: DataTableProps) {
const { t } = useTranslation()
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [sorting, setSorting] = useState<SortingState>([])
@@ -132,7 +134,7 @@ export function AccountsDetailTable({ columns, data }: DataTableProps) {
colSpan={columns.length}
className='h-24 text-center'
>
No results.
{t('common.table.noResults')}
</TableCell>
</TableRow>
)}

View File

@@ -32,6 +32,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
@@ -40,6 +41,7 @@ interface Props {
}
export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
return (
<Dialog
open={open}
@@ -49,9 +51,9 @@ export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) {
>
<DialogContent className='sm:max-w-xl'>
<DialogHeader className='text-left'>
<DialogTitle>Acl</DialogTitle>
<DialogTitle>{t('settings.acl')}</DialogTitle>
<DialogDescription>
ACL rules for access tokens include IP whitelist verification and rate limit enforcement.
{t('accessTokens.aclRulesForAccessTokens')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[33rem] w-full pr-4 -mr-4 py-1'>

View File

@@ -10,11 +10,11 @@
//
// 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
// 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/>.
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
import { z } from 'zod'
@@ -51,6 +51,7 @@ import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { VirtualizedSelect } from '@/components/virtualized-select'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
const isValidIP = (ip: string) => {
const ipv4Regex = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/;
@@ -58,14 +59,31 @@ const isValidIP = (ip: string) => {
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
};
const rateLimitSchema = z.object({
quota: z.optional(z.number().int().positive({ message: "Quota must be a positive integer." })),
interval: z.optional(z.number().int().positive({ message: "Interval must be a positive integer." })),
const RateLimitBaseSchema = z.object({
quota: z.optional(z.number()),
interval: z.optional(z.number()),
});
const accessControlSchema = z.object({
const AccessControlBaseSchema = z.object({
ip_whitelist: z.string().optional(),
rate_limit: rateLimitSchema.optional(),
rate_limit: z.optional(RateLimitBaseSchema),
});
const AccessTokenBaseSchema = z.object({
accounts: z.array(z.number()),
description: z.optional(z.string()),
acl: z.optional(AccessControlBaseSchema),
});
export type AccessTokenForm = z.infer<typeof AccessTokenBaseSchema>;
const getRateLimitSchema = (t: (key: string) => string) => z.object({
quota: z.optional(z.number().int().positive({ message: t('accessTokens.quotaMustBeAPositiveInteger') })),
interval: z.optional(z.number().int().positive({ message: t('accessTokens.intervalMustBeAPositiveInteger') })),
});
const getAccessControlSchema = (t: (key: string) => string) => AccessControlBaseSchema.extend({
rate_limit: getRateLimitSchema(t).optional(),
}).transform((data) => {
if (data.ip_whitelist) {
const ips = data.ip_whitelist
@@ -89,7 +107,7 @@ const accessControlSchema = z.object({
return true;
},
{
message: 'Invalid IP addresses found. Please enter valid IPv4 or IPv6 addresses.',
message: t('accessTokens.invalidIpAddressesFound'),
path: ['ip_whitelist'],
}
).transform((data) => {
@@ -108,17 +126,15 @@ const accessControlSchema = z.object({
return data;
});
const accessTokenFormSchema = z.object({
const getAccessTokenFormSchema = (t: (key: string) => string) => AccessTokenBaseSchema.extend({
accounts: z
.array(z.number())
.min(1, { message: "At least one account is required." }),
.min(1, { message: t('accessTokens.atLeastOneAccountIsRequired') }),
description: z
.optional(z.string().max(255, { message: "Description must not exceed 255 characters." })),
acl: z.optional(accessControlSchema),
.optional(z.string().max(255, { message: t('accessTokens.descriptionMustNotExceed255Characters') })),
acl: z.optional(getAccessControlSchema(t)),
});
export type AccessTokenForm = z.infer<typeof accessTokenFormSchema>;
interface Props {
currentRow?: AccessToken
open: boolean
@@ -134,14 +150,15 @@ const defaultValues = {
export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow
const queryClient = useQueryClient();
const form = useForm<AccessTokenForm>({
resolver: zodResolver(accessTokenFormSchema),
resolver: zodResolver(getAccessTokenFormSchema(t)),
defaultValues: isEdit
? {
accounts: currentRow.accounts.map(value => value.id),
description: currentRow.description,
description: currentRow.description ?? undefined,
acl: currentRow.acl
? {
ip_whitelist: currentRow.acl.ip_whitelist
@@ -168,9 +185,9 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
function handleSuccess() {
toast({
title: `Access token ${isEdit ? 'Updated' : 'Created'}`,
description: `Your access token has been successfully ${isEdit ? 'updated' : 'created'}.`,
action: <ToastAction altText="Close">Close</ToastAction>,
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updated') : t('accessTokens.created')}`,
description: t('accessTokens.yourAccessTokenHasBeenSuccessfully', { action: isEdit ? t('accessTokens.updated').toLowerCase() : t('accessTokens.created').toLowerCase() }),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['access-tokens'] });
@@ -181,13 +198,13 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
function handleError(error: AxiosError) {
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
t('accessTokens.updateOrCreationFailed', { action: isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed') });
toast({
variant: "destructive",
title: `Access token ${isEdit ? 'Update' : 'Creation'} Failed`,
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed')}`,
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
@@ -233,10 +250,10 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
>
<DialogContent className='max-w-4xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? 'Edit Token' : 'Add New'}</DialogTitle>
<DialogTitle>{isEdit ? t('accessTokens.editToken') : t('accessTokens.addNew')}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the access token here. ' : 'Create new access token here. '}
Click save when you&apos;re done.
{isEdit ? t('accessTokens.updateTheAccessTokenHere') : t('accessTokens.createNewAccessTokenHere')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[28rem] w-full pr-4 -mr-4 py-1'>
@@ -251,7 +268,7 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
name='accounts'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>Accounts:</FormLabel>
<FormLabel className='mb-1'>{t('accessTokens.accounts')}:</FormLabel>
<FormControl>
<VirtualizedSelect
multiple
@@ -262,13 +279,13 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
const numberArray = options.map((v) => parseInt(v, 10));
return field.onChange(numberArray);
}}
defaultValue={`${field.value}`}
placeholder="Select accounts"
value={field.value.map(String)}
placeholder={t('accessTokens.selectAccounts')}
/>
</FormControl>
<FormMessage />
<FormDescription>
Select multiple accounts for the access token's authorization scope.
{t('accessTokens.selectMultipleAccountsForTheAccessToken')}
</FormDescription>
</FormItem>
)}
@@ -278,16 +295,16 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
name="acl.ip_whitelist"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0">
<FormLabel className='mb-1'>IP Whitelist:</FormLabel>
<FormLabel className='mb-1'>{t('settings.acl')}:</FormLabel>
<FormControl>
<Textarea
placeholder="Enter one IP address per line, e.g.:\n192.168.1.1\n192.168.1.2"
placeholder={t('accessTokens.enterOneIpAddressPerLine')}
{...field}
className="max-h-[500px] min-h-[180px]"
/>
</FormControl>
<FormDescription>
A list of IP addresses allowed to access the resource. Enter one IP address per line. (Optional)
{t('accessTokens.aListOfIpAddressesAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -299,17 +316,17 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
name="acl.rate_limit.quota"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>Quota:</FormLabel>
<FormLabel className='mb-1'>{t('accessTokens.quota')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter quota, e.g., 100"
placeholder={t('accessTokens.enterQuota')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
The maximum number of requests allowed within the interval. (Optional)
{t('accessTokens.theMaximumNumberOfRequestsAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -320,17 +337,17 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
name="acl.rate_limit.interval"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>Interval (seconds):</FormLabel>
<FormLabel className='mb-1'>{t('accessTokens.interval')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter interval in seconds, e.g., 60"
placeholder={t('accessTokens.enterIntervalInSeconds')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
The time window (in seconds) for the rate limit. (Optional)
{t('accessTokens.theTimeWindowForTheRateLimit')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -342,15 +359,15 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
name='description'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>Description:</FormLabel>
<FormLabel className='mb-1'>{t('settings.description')}:</FormLabel>
<FormControl>
<Textarea
placeholder='Describe the purpose of the access token'
placeholder={t('accessTokens.describeThePurposeOfTheAccessToken')}
{...field}
className="max-h-[240px] min-h-[80px]"
/>
</FormControl>
<FormDescription>(Optional)</FormDescription>
<FormDescription>{t('oauth2.optional')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -372,11 +389,11 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
<span>
{isEdit
? updateMutation.isPending
? "Updating..."
: "Save changes"
? t('accessTokens.updating')
: t('accessTokens.saveChanges')
: createMutation.isPending
? "Creating..."
: "Save"}
? t('accessTokens.creating')
: t('accessTokens.save')}
</span>
</span>
</Button>
@@ -384,4 +401,4 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
</DialogContent>
</Dialog>
)
}
}

View File

@@ -23,15 +23,15 @@ import LongText from '@/components/long-text'
import { AccessToken } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format, formatDistanceToNow } from 'date-fns'
import { format, formatDistanceToNow, Locale } from 'date-fns'
import { AccountCellAction } from './account-action'
import { AclCellAction } from './acl-action'
export const columns: ColumnDef<AccessToken>[] = [
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
{
accessorKey: 'token',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Token' />
<DataTableColumnHeader column={column} title={t('settings.token')} />
),
cell: ({ row }) => {
return <LongText className='w-40'>{row.original.token}</LongText>
@@ -43,7 +43,7 @@ export const columns: ColumnDef<AccessToken>[] = [
{
accessorKey: 'accounts',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Accounts' />
<DataTableColumnHeader column={column} title={t('settings.accounts')} />
),
cell: AccountCellAction,
meta: { className: 'w-10 text-center' },
@@ -60,7 +60,7 @@ export const columns: ColumnDef<AccessToken>[] = [
{
id: 'acl',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Acl' />
<DataTableColumnHeader column={column} title={t('settings.acl')} />
),
cell: AclCellAction,
meta: { className: 'w-8 text-center' },
@@ -69,7 +69,7 @@ export const columns: ColumnDef<AccessToken>[] = [
{
accessorKey: 'description',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Description' />
<DataTableColumnHeader column={column} title={t('settings.description')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.description}</LongText>
@@ -81,7 +81,7 @@ export const columns: ColumnDef<AccessToken>[] = [
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Created At' />
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
),
cell: ({ row }) => {
const created_at = row.original.created_at;
@@ -94,7 +94,7 @@ export const columns: ColumnDef<AccessToken>[] = [
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Updated At' />
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
),
cell: ({ row }) => {
const updated_at = row.original.updated_at;
@@ -107,14 +107,14 @@ export const columns: ColumnDef<AccessToken>[] = [
{
accessorKey: 'last_access_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Last Access' />
<DataTableColumnHeader column={column} title={t('settings.lastAccess')} />
),
cell: ({ row }) => {
const last_access_at = row.original.last_access_at;
if (last_access_at === 0) {
return <LongText className='max-w-40'>Not used yet</LongText>;
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
}
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true });
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
return <LongText className='max-w-40'>{result}</LongText>;
},
meta: { className: 'w-40' },

View File

@@ -33,6 +33,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
@@ -48,7 +49,7 @@ export function DataTableColumnHeader<TData, TValue>({
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
const { t } = useTranslation()
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
@@ -71,16 +72,16 @@ export function DataTableColumnHeader<TData, TValue>({
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Asc
{t('table.asc')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Desc
{t('table.desc')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Hide
{t('table.hide')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -20,8 +20,6 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
@@ -32,6 +30,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
@@ -44,18 +43,18 @@ export function DataTablePagination<TData>({
showSelected = true,
showPageSizeSelector = true
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation();
return (
<div className='flex items-center justify-between overflow-auto px-2'>
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
{table.getFilteredRowModel().rows.length} {t("table.results")}
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 rows per page.
10 {t("table.rowsPerPage")}.
</div>}
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
{showPageSizeSelector && <div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
@@ -75,26 +74,17 @@ export function DataTablePagination<TData>({
</Select>
</div>}
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
{t("table.page")} {table.getState().pagination.pageIndex + 1}{" "}
{t("table.of")} {table.getPageCount()}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -103,18 +93,9 @@ export function DataTablePagination<TData>({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>

View File

@@ -31,12 +31,14 @@ import {
} from '@/components/ui/dropdown-menu'
import { useAccessTokensContext } from '../context'
import { AccessToken } from '../data/schema'
import { useTranslation } from 'react-i18next'
interface DataTableRowActionsProps {
row: Row<AccessToken>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccessTokensContext()
return (
<>
@@ -47,7 +49,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
<span className='sr-only'>{t('accessTokens.actions.openMenu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
@@ -57,7 +59,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('edit')
}}
>
Edit
{t('accessTokens.actions.edit')}
<DropdownMenuShortcut>
<IconEdit size={16} />
</DropdownMenuShortcut>
@@ -70,7 +72,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}}
className='!text-red-500'
>
Delete
{t('accessTokens.actions.delete')}
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>

View File

@@ -19,6 +19,7 @@
import { Table } from '@tanstack/react-table'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
interface DataTableToolbarProps<TData> {
table: Table<TData>
@@ -27,11 +28,12 @@ interface DataTableToolbarProps<TData> {
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between'>
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
<Input
placeholder='Filter access token...'
placeholder={t('settings.filterAccessToken')}
value={(table.getState().globalFilter as string) ?? ''}
onChange={(event) => {
table.setGlobalFilter(event.target.value);

View File

@@ -29,6 +29,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { delete_access_token } from '@/api/access-tokens/api'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
@@ -37,6 +38,7 @@ interface Props {
}
export function TokenDeleteDialog({ open, onOpenChange, currentRow }: Props) {
const { t } = useTranslation()
const [value, setValue] = useState('')
const queryClient = useQueryClient();
@@ -49,9 +51,9 @@ export function TokenDeleteDialog({ open, onOpenChange, currentRow }: Props) {
function handleSuccess() {
toast({
title: 'Access Token Deleted',
description: 'Your access token has been successfully deleted.',
action: <ToastAction altText="Close">Close</ToastAction>,
title: t('accessTokens.accessTokenDeleted'),
description: t('accessTokens.yourAccessTokenHasBeenSuccessfullyDeleted'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['access-tokens'] });
@@ -61,13 +63,13 @@ export function TokenDeleteDialog({ open, onOpenChange, currentRow }: Props) {
function handleError(error: AxiosError) {
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
'Delete failed, please try again later';
t('dialogs.deleteFailed');
toast({
variant: "destructive",
title: `Access token delete Failed`,
title: t('accessTokens.accessTokenDeleteFailed'),
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
@@ -91,37 +93,37 @@ export function TokenDeleteDialog({ open, onOpenChange, currentRow }: Props) {
className='mr-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete Token
{t('accessTokens.deleteToken')}
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
Are you sure you want to delete{' '}
{t('dialogs.confirmDeleteDesc')}
<span className='font-bold'>{currentRow.token}</span>?
<br />
This action will permanently remove the token from the system. This cannot be undone.
{t('accessTokens.thisActionWillPermanentlyRemoveTheToken')}
</p>
<Label className='my-2'>
Token:
{t('settings.token')}:
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder='Enter token to confirm deletion.'
placeholder={t('accessTokens.enterTokenToConfirmDeletion')}
className="mt-2"
/>
</Label>
<Alert variant='destructive'>
<AlertTitle>Warning!</AlertTitle>
<AlertTitle>{t('oauth2.warning')}</AlertTitle>
<AlertDescription>
Please be carefull, this operation can not be rolled back.
{t('oauth2.pleaseBeCareful')}
</AlertDescription>
</Alert>
</div>
}
confirmText='Delete'
confirmText={t('common.delete')}
destructive
/>
)

View File

@@ -22,7 +22,7 @@ import useDialogState from '@/hooks/use-dialog-state'
import { Button } from '@/components/ui/button'
import { Main } from '@/components/layout/main'
import { TokensActionDialog } from './components/action-dialog'
import { columns } from './components/columns'
import { getColumns } from './components/columns'
import { TokenDeleteDialog } from './components/delete-dialog'
import { AccessTokensTable } from './components/access-token-table'
import AccessTokensProvider, {
@@ -37,8 +37,13 @@ import { useQuery } from '@tanstack/react-query'
import { list_access_tokens } from '@/api/access-tokens/api'
import { TableSkeleton } from '@/components/table-skeleton'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
export default function AccessTokens() {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
// Dialog states
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
@@ -48,49 +53,51 @@ export default function AccessTokens() {
queryFn: list_access_tokens,
})
const columns = getColumns(t, locale)
return (
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
{/* ===== Top Heading ===== */}
<FixedHeader />
<Main>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Access Token</h2>
<p className="text-muted-foreground">
Manage your access tokens, their access controls, IP whitelists, and rate-limiting settings here.
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>Add</span> <Plus size={18} />
</Button>
</div>
</div>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="RustMailer Logo"
/>
<h3 className="mt-4 text-lg font-semibold">No Access Tokens</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
You haven't created any access tokens yet. Create one to start accessing the API securely.
</p>
<Button onClick={() => setOpen('add')}>Create Token</Button>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
<p className="text-muted-foreground">
{t('accessTokens.description')}
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('common.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
</div>
)}
</div>
</Main>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('accessTokens.noTokensDesc')}
</p>
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
</div>
</div>
)}
</div>
</Main>
<TokensActionDialog

View File

@@ -24,6 +24,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
@@ -32,6 +33,7 @@ interface Props {
}
export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
const { t } = useTranslation()
return (
<Dialog
open={open}
@@ -46,7 +48,7 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
<Tabs defaultValue="account" className="w-full">
<TabsList className="grid w-full grid-cols-1">
<TabsTrigger value="account">Account Details</TabsTrigger>
<TabsTrigger value="account">{t('accounts.accountDetails')}</TabsTrigger>
</TabsList>
<TabsContent value="account">
@@ -56,44 +58,44 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
<CardContent className="mt-4">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">ID:</span>
<span className="text-muted-foreground">{t('accounts.id')}:</span>
<span>{currentRow.id}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Email:</span>
<span className="text-muted-foreground">{t('accounts.email')}:</span>
<span>{currentRow.email}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Name:</span>
<span>{currentRow.name ?? "n/a"}</span>
<span className="text-muted-foreground">{t('accounts.name')}:</span>
<span>{currentRow.name ?? t('accounts.notAvailable')}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Enabled:</span>
<span className="text-muted-foreground">{t('accounts.enabled')}:</span>
<Checkbox checked={currentRow.enabled} disabled />
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Incremental Sync Interval:</span>
<span>every {currentRow.sync_interval_min} minutes</span>
<span className="text-muted-foreground">{t('accounts.incrementalSyncInterval')}:</span>
<span>{t('accounts.everyMinutes', { minutes: currentRow.sync_interval_min })}</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground">Capabilities:</span>
<span className="text-muted-foreground">{t('accounts.capabilities')}:</span>
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
{currentRow.capabilities ? currentRow.capabilities.join(", ") : "n/a"}
{currentRow.capabilities ? currentRow.capabilities.join(", ") : t('accounts.notAvailable')}
</code>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Date Selection:</span>
<span className="text-muted-foreground">{t('accounts.dateSelection')}:</span>
<span>
{currentRow.date_since?.fixed
? currentRow.date_since.fixed
? t('accounts.since') + ' ' + currentRow.date_since.fixed
: currentRow.date_since?.relative
? `recent ${currentRow.date_since.relative.value} ${currentRow.date_since.relative.unit}`
: "n/a"}
? t('accounts.recent') + ' ' + currentRow.date_since.relative.value + ' ' + currentRow.date_since.relative.unit
: t('accounts.notAvailable')}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Folder Limit:</span>
<span>{currentRow.folder_limit ? currentRow.folder_limit : "n/a"}</span>
<span className="text-muted-foreground">{t('accounts.folderLimit')}:</span>
<span>{currentRow.folder_limit ? currentRow.folder_limit : t('accounts.notAvailable')}</span>
</div>
</div>
</CardContent>
@@ -102,24 +104,28 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
{/* Server Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Server Configuration (IMAP)</CardTitle>
<CardTitle>{t('accounts.serverConfiguration')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Host:</span>
<span className="text-muted-foreground">{t('accounts.host')}:</span>
<span>{currentRow.imap?.host}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Port:</span>
<span className="text-muted-foreground">{t('accounts.port')}:</span>
<span>{currentRow.imap?.port}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Encryption:</span>
<span className="text-muted-foreground">{t('accounts.encryption')}:</span>
<span>{currentRow.imap?.encryption}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Auth:</span>
<span className="text-muted-foreground">{t('accounts.useDangerous')}:</span>
<span>{`${currentRow.use_dangerous}`}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.auth')}:</span>
{currentRow.imap?.auth.auth_type === "OAuth2" ? (
<Badge variant="outline" className="bg-blue-100 text-blue-800">
OAuth2
@@ -131,7 +137,7 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Use Proxy:</span>
<span className="text-muted-foreground">{t('accounts.useProxyField')}:</span>
<span>{currentRow.imap?.use_proxy ? "true" : "false"}</span>
</div>
</div>
@@ -141,13 +147,13 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
{/* Sync Folders Card */}
<Card>
<CardHeader>
<CardTitle>Sync Folders</CardTitle>
<CardTitle>{t('accounts.syncFoldersTitle')}</CardTitle>
</CardHeader>
<CardContent>
{currentRow.sync_folders?.length ? (
<div className="space-y-2">
<div className="text-sm mt-2 text-muted-foreground">
{currentRow.sync_folders.length} folder(s) configured for sync
{t('accounts.foldersConfiguredForSync', { count: currentRow.sync_folders.length })}
</div>
<ScrollArea className="h-[300px] rounded-md border">
<div className="p-2">

View File

@@ -31,11 +31,11 @@ import Step1 from './step1';
import Step2 from './step2';
import Step3 from './step3';
import Step4 from './step4';
import CompleteStep from './complete-step';
import { create_account, autoconfig, update_account } from '@/api/account/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ToastAction } from '@/components/ui/toast';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
const encryptionSchema = z.union([
z.literal('Ssl'),
@@ -48,48 +48,43 @@ const authTypeSchema = z.union([
z.literal('OAuth2'),
]);
const authConfigSchema = (isEdit: boolean) =>
const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
z.object({
auth_type: authTypeSchema,
password: z.string().optional(), // Always optional at base level
password: z.string().optional(),
}).refine(
(data) => {
// Only validate password when:
// 1. Auth type is Password
// 2. In create mode (not edit)
if (data.auth_type === 'Password' && !isEdit) {
return !!data.password?.trim();
}
return true;
},
{
message: 'Password is required when auth method is Password',
message: t('validation.passwordRequired'),
path: ['password'],
}
);
const imapConfigSchema = (isEdit: boolean) =>
const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
z.object({
host: z.string({ required_error: 'IMAP host is required' }).min(1, { message: 'IMAP host cannot be empty' }),
port: z.number().int().min(0, { message: 'IMAP port must be a positive integer' }).max(65535, { message: 'IMAP port must be less than 65536' }),
host: z.string({ required_error: t('validation.imapHostRequired') }).min(1, { message: t('validation.imapHostCannotBeEmpty') }),
port: z.number().int().min(0, { message: t('validation.imapPortMustBePositive') }).max(65535, { message: t('validation.imapPortMustBeLessThan65536') }),
encryption: encryptionSchema,
auth: authConfigSchema(isEdit),
auth: getAuthConfigSchema(isEdit, t),
use_proxy: z.number().optional(),
});
const relativeDateSchema = z.object({
unit: z.enum(["Days", "Months", "Years"], { message: "Please select a unit" }),
value: z.number({ message: 'Please enter a value' }).int().min(1, "Must be at least 1"),
const getRelativeDateSchema = (t: (key: string) => string) => z.object({
unit: z.enum(["Days", "Months", "Years"], { message: t('accounts.selectUnit') }),
value: z.number({ message: t('accounts.enterValue') }).int().min(1, t('accounts.mustBeAtLeast1')),
});
const dateSelectionSchema = z.union([
z.object({ fixed: z.string({ message: "Please select a date" }) },),
z.object({ relative: relativeDateSchema }),
const getDateSelectionSchema = (t: (key: string) => string) => z.union([
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) },),
z.object({ relative: getRelativeDateSchema(t) }),
z.undefined(),
]);
// Define static Account type to avoid z.infer issue with dynamic schema
export type Account = {
name?: string;
email: string;
@@ -104,6 +99,7 @@ export type Account = {
use_proxy?: number;
};
enabled: boolean;
use_dangerous: boolean;
date_since?: {
fixed?: string;
relative?: {
@@ -115,19 +111,20 @@ export type Account = {
sync_interval_min: number;
};
const accountSchema = (isEdit: boolean) =>
const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
z.object({
name: z.string().optional(),
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
imap: imapConfigSchema(isEdit),
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
imap: getImapConfigSchema(isEdit, t),
enabled: z.boolean(),
date_since: dateSelectionSchema.optional(),
use_dangerous: z.boolean(),
date_since: getDateSelectionSchema(t).optional(),
folder_limit: z
.number({ invalid_type_error: 'Folder limit must be a number' })
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
.int()
.min(100, { message: 'Folder limit must be at least 100' })
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
.optional(),
sync_interval_min: z.number({ invalid_type_error: 'Incremental sync interval must be a number' }).int().min(10, { message: 'Incremental sync interval must be at least 10 minutes' }),
sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
});
type Step = {
@@ -137,28 +134,17 @@ type Step = {
};
export type Steps = [
{ id: "complete"; name: "Complete"; fields: [] },
...Step[]
];
const steps: Steps = [
{ id: "complete", name: "Complete", fields: [] },
{
id: "step-1",
name: "Email Address",
fields: ["email"],
},
{
id: "step-2",
name: "IMAP",
fields: ["imap"],
},
{ id: "step-3", name: "Sync Preferences", fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
{ id: "step-4", name: "Summary", fields: [] },
const getSteps = (t: (key: string) => string): Steps => [
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "name"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
];
const LAST_STEP = steps.length - 1;
const COMPLETE_STEP = 0;
const LAST_STEP = 4;
interface Props {
currentRow?: AccountModel;
@@ -180,6 +166,7 @@ const defaultValues: Account = {
use_proxy: undefined
},
enabled: true,
use_dangerous: false,
date_since: undefined,
folder_limit: undefined,
sync_interval_min: 10,
@@ -195,35 +182,36 @@ const emptyImap: ImapConfig = {
const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
const imap = { ...(currentRow.imap ?? emptyImap) };
// Handle password and use_proxy conversion
imap.auth = { ...imap.auth, password: undefined };
if (imap.use_proxy === null) {
imap.use_proxy = undefined;
}
let account = {
name: currentRow.name === null ? undefined : currentRow.name,
return {
name: currentRow.name ?? undefined,
email: currentRow.email,
imap,
enabled: currentRow.enabled,
use_dangerous: currentRow.use_dangerous,
date_since: currentRow.date_since ?? undefined,
folder_limit: currentRow.folder_limit ?? undefined,
sync_interval_min: currentRow.sync_interval_min ?? 10,
};
return account;
};
export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation();
const steps = getSteps(t);
const isEdit = !!currentRow;
const [currentStep, setCurrentStep] = React.useState(1);
const { toast } = useToast();
const [autoConfigLoading, setAutoConfigLoading] = React.useState(false);
const accountSchema = getAccountSchema(isEdit, t);
const form = useForm<Account>({
mode: "all",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema(isEdit)),
resolver: zodResolver(accountSchema),
});
const queryClient = useQueryClient();
@@ -242,9 +230,9 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
function handleSuccess() {
toast({
title: `Account ${isEdit ? 'Updated' : 'Created'}`,
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
action: <ToastAction altText="Close">Close</ToastAction>,
title: isEdit ? t('accounts.accountUpdated') : t('accounts.accountCreated'),
description: isEdit ? t('accounts.accountUpdatedDesc') : t('accounts.accountCreatedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
@@ -256,13 +244,13 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
(isEdit ? t('accounts.updateFailed') : t('accounts.creationFailed'));
toast({
variant: "destructive",
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
title: isEdit ? t('accounts.accountUpdateFailed') : t('accounts.accountCreationFailed'),
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
@@ -282,6 +270,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
},
},
enabled: data.enabled,
use_dangerous: data.use_dangerous,
date_since: data.date_since,
folder_limit: data.folder_limit,
sync_interval_min: data.sync_interval_min,
@@ -289,11 +278,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
if (isEdit) {
updateMutation.mutate(commonData);
} else {
const payload = {
...commonData,
account_type: "IMAP",
};
createMutation.mutate(payload);
createMutation.mutate({ ...commonData, account_type: "IMAP" });
}
},
[isEdit, updateMutation, createMutation]
@@ -302,30 +287,21 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const handleNav = async (index: number) => {
let isValid = true;
let failedStep = currentStep;
for (let i = currentStep; i < index && isValid; i++) {
for (let i = currentStep - 1; i < index - 1 && isValid; i++) {
isValid = await form.trigger(steps[i].fields);
if (!isValid) {
failedStep = i;
}
}
if (isValid) {
setCurrentStep(index);
} else {
setCurrentStep(failedStep);
if (!isValid) failedStep = i;
}
if (isValid) setCurrentStep(index);
else setCurrentStep(failedStep);
};
async function handleContinue() {
const isValid = await form.trigger(steps[currentStep].fields);
if (!isValid) {
return;
}
const isValid = await form.trigger(steps[currentStep - 1].fields);
if (!isValid) return;
if (currentStep === 1) {
let allValues = form.getValues();
if (
allValues.imap.host.trim() !== "" &&
allValues.imap.port > 0
) {
if (allValues.imap.host.trim() !== "" && allValues.imap.port > 0) {
handleNav(currentStep + 1);
return;
}
@@ -338,15 +314,12 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
form.setValue('imap.host', result.imap.host);
form.setValue('imap.port', result.imap.port);
form.setValue('imap.encryption', result.imap.encryption);
if (result.oauth2) {
form.setValue('imap.auth.auth_type', 'OAuth2');
}
if (result.oauth2) form.setValue('imap.auth.auth_type', 'OAuth2');
}
setAutoConfigLoading(false);
} catch (error) {
console.error('Auto-configuration failed:', error);
setAutoConfigLoading(false);
}
setAutoConfigLoading(false);
handleNav(currentStep + 1);
} else {
handleNav(currentStep + 1);
@@ -364,75 +337,58 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
>
<DialogContent className='max-w-5xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
Click save when you're done.
{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[38rem] w-full pr-4 -mr-4 py-1">
<>
{/* Mobile Steps (hidden on desktop) */}
{currentStep !== COMPLETE_STEP && (
<div className="flex my-5 space-x-4 md:hidden">
{steps.map(
(step, index) =>
index !== COMPLETE_STEP && (
<div className="z-20 my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-9 rounded-full border font-bold ${`step-${currentStep}` === step.id ? "" : "bg-gray-200 text-black"
}`}
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
onClick={() => handleNav(index)}
>
{index}
</Button>
</div>
)
)}
</div>
)}
<div className="flex my-5 space-x-4 md:hidden">
{steps.map((step, index) => (
<Button
key={step.id}
className={`size-9 rounded-full border font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
}`}
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
))}
</div>
<div className="w-full max-w-full p-4">
<div className="flex md:h-min rounded-xl md:rounded-2xl p-4">
{currentStep !== COMPLETE_STEP && (
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
{steps.map(
(step, index) =>
index !== COMPLETE_STEP && (
<div className="my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-8 border rounded-full text-sm font-bold ${`step-${currentStep}` === step.id
? "bg-primary text-white"
: "bg-gray-200 text-black"
}`}
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
onClick={() => handleNav(index)}
>
{index}
</Button>
<div className="flex flex-col items-baseline uppercase ml-5">
<span className="text-xs">Step {index}</span>
<span className="font-bold text-sm tracking-wider">{step.name}</span>
</div>
</div>
)
)}
</div>
)}
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
{steps.map((step, index) => (
<div className="my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-8 border rounded-full text-sm font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
}`}
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
<div className="flex flex-col items-baseline uppercase ml-5">
<span className="text-xs">{t('accounts.step', { index: index + 1 })}</span>
<span className="font-bold text-sm tracking-wider">{step.name}</span>
</div>
</div>
))}
</div>
<Form {...form}>
<form
id="account-register-form"
className={`flex-grow flex flex-col px-4 md:px-8 lg:px-12 ${currentStep !== COMPLETE_STEP ? 'ml-[240px]' : ''
}`}
className="flex-grow flex flex-col px-4 md:px-8 lg:px-12 ml-[240px]"
onSubmit={form.handleSubmit(onSubmit)}
>
{currentStep === 1 && <Step1 isEdit={isEdit} />}
{currentStep === 2 && <Step2 isEdit={isEdit} />}
{currentStep === 3 && <Step3 />}
{currentStep === 4 && <Step4 />}
{currentStep === COMPLETE_STEP && <CompleteStep />}
</form>
</Form>
</div>
@@ -440,60 +396,35 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
</>
</ScrollArea>
<DialogFooter className="flex flex-wrap gap-2">
<Button
disabled={currentStep === 1 || currentStep === COMPLETE_STEP}
type="button"
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm disabled:invisible"
onClick={() => {
handleNav(currentStep - 1);
}}
>
Go Back
</Button>
<Button
disabled={currentStep === LAST_STEP || currentStep === COMPLETE_STEP}
type="button"
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 disabled:hidden text-sm"
onClick={handleContinue}
>
{autoConfigLoading ? (
<>
<svg
className="animate-spin h-5 w-5 mr-3 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Auto-configuring...</span>
</>
) : (
"Continue"
)}
</Button>
<Button
disabled={currentStep !== LAST_STEP}
type="submit"
form="account-register-form"
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 disabled:hidden md:rounded-lg"
>
{isEdit ? "Save changes" : "Submit"}
</Button>
{currentStep > 1 && (
<Button
type="button"
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm"
onClick={() => setCurrentStep(currentStep - 1)}
>
{t('accounts.goBack')}
</Button>
)}
{currentStep < LAST_STEP && (
<Button
type="button"
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 text-sm"
onClick={handleContinue}
>
{autoConfigLoading ? t('accounts.autoConfiguring') : t('accounts.continue')}
</Button>
)}
{currentStep === LAST_STEP && (
<Button
type="submit"
form="account-register-form"
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 md:rounded-lg"
>
{isEdit ? t('accounts.saveChanges') : t('accounts.submit')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
}

View File

@@ -27,12 +27,16 @@ import { format } from 'date-fns'
import { OAuth2Action } from './oauth2-action'
import { RunningStateCellAction } from './running-state-action'
import { EnableAction } from './enable-action'
import { useTranslation } from 'react-i18next'
export const columns: ColumnDef<AccountModel>[] = [
export function useColumns(): ColumnDef<AccountModel>[] {
const { t } = useTranslation()
return [
{
accessorKey: "id",
header: ({ column }) => (
<DataTableColumnHeader column={column} title='ID' />
<DataTableColumnHeader column={column} title={t('accounts.id')} />
),
cell: ({ row }) => {
return <LongText>{row.original.id}</LongText>
@@ -43,7 +47,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Email' />
<DataTableColumnHeader column={column} title={t('accounts.email')} />
),
cell: ({ row }) => {
return <LongText>{row.original.email}</LongText>
@@ -53,7 +57,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: "enabled",
header: ({ column }) => (
<DataTableColumnHeader className="ml-4" column={column} title='Enabled' />
<DataTableColumnHeader className="ml-4" column={column} title={t('accounts.enabled')} />
),
cell: EnableAction,
meta: { className: 'w-8 text-center' },
@@ -62,7 +66,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
id: 'auth_type',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Auth' />
<DataTableColumnHeader column={column} title={t('accounts.auth')} />
),
cell: OAuth2Action,
meta: { className: 'w-8' },
@@ -72,7 +76,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
id: 'account_type',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Type' />
<DataTableColumnHeader column={column} title={t('accounts.type')} />
),
cell: ({ row }) => {
return <LongText>{row.original.account_type}</LongText>
@@ -84,7 +88,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: "sync_interval_sec",
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Inc Sync' />
<DataTableColumnHeader column={column} title={t('accounts.incSync')} />
),
cell: ({ row }) => {
let account_type = row.original.account_type;
@@ -99,7 +103,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
id: 'running_state',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='State' />
<DataTableColumnHeader column={column} title={t('accounts.state')} />
),
cell: RunningStateCellAction,
meta: { className: 'w-36' },
@@ -108,7 +112,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Created At' />
<DataTableColumnHeader column={column} title={t('accounts.createdAt')} />
),
cell: ({ row }) => {
const created_at = row.original.created_at;
@@ -121,7 +125,7 @@ export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Updated At' />
<DataTableColumnHeader column={column} title={t('accounts.updatedAt')} />
),
cell: ({ row }) => {
const updated_at = row.original.updated_at;
@@ -135,4 +139,5 @@ export const columns: ColumnDef<AccountModel>[] = [
id: 'actions',
cell: DataTableRowActions,
},
]
]
}

View File

@@ -1,60 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { CheckCircle } from "lucide-react"; // Using Lucide icon library
import { Button } from "@/components/ui/button"; // Using custom button component
export default function CompleteStep() {
return (
<div className="flex flex-col items-center justify-center h-[25rem] p-6">
{/* Success Icon */}
<div className="mb-6 text-green-500">
<CheckCircle className="w-16 h-16" />
</div>
{/* Success Message */}
<h1 className="text-3xl font-bold mb-4">Registration Successful!</h1>
<p className="text-lg text-gray-600 mb-8 text-center">
Your email account has been successfully added.
</p>
{/* Action Buttons */}
<div className="mt-8 flex gap-4">
<Button
variant="default"
onClick={() => {
// Redirect to home page
window.location.href = "/";
}}
>
Authorize via OAuth2
</Button>
<Button
variant="outline"
onClick={() => {
// View details
window.location.href = "/accounts";
}}
>
View Details
</Button>
</div>
</div>
);
}

View File

@@ -33,6 +33,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
@@ -48,7 +49,7 @@ export function DataTableColumnHeader<TData, TValue>({
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
const { t } = useTranslation()
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
@@ -71,16 +72,16 @@ export function DataTableColumnHeader<TData, TValue>({
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Asc
{t('table.asc')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Desc
{t('table.desc')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Hide
{t('table.hide')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -32,12 +32,14 @@ import {
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { Mailbox, MessageSquareMore } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface DataTableRowActionsProps {
row: Row<AccountModel>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const account_type = row.original.account_type;
@@ -51,7 +53,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
<span className='sr-only'>{t('accounts.openMenu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
@@ -66,7 +68,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}
}}
>
Edit
{t('accounts.edit')}
<DropdownMenuShortcut>
<IconEdit size={16} />
</DropdownMenuShortcut>
@@ -77,7 +79,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('sync-folders')
}}
>
Sync Folders
{t('accounts.syncFolders')}
<DropdownMenuShortcut>
<Mailbox size={16} />
</DropdownMenuShortcut>
@@ -88,7 +90,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('detail')
}}
>
Detail
{t('accounts.detail')}
<DropdownMenuShortcut>
<MessageSquareMore size={16} />
</DropdownMenuShortcut>
@@ -101,7 +103,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}}
className='!text-red-500'
>
Delete
{t('accounts.delete')}
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>

View File

@@ -19,6 +19,7 @@
import { Table } from '@tanstack/react-table'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
interface DataTableToolbarProps<TData> {
table: Table<TData>
@@ -27,11 +28,12 @@ interface DataTableToolbarProps<TData> {
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between'>
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
<Input
placeholder='Filter account...'
placeholder={t('settings.filterAccount')}
value={(table.getState().globalFilter as string) ?? ''}
onChange={(event) => {
table.setGlobalFilter(event.target.value);

View File

@@ -29,6 +29,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { remove_account } from '@/api/account/api'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
@@ -37,14 +38,15 @@ interface Props {
}
export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
const { t } = useTranslation()
const [value, setValue] = useState('')
const queryClient = useQueryClient();
function handleSuccess() {
toast({
title: 'Account Deleted',
description: 'Your account has been successfully deleted.',
action: <ToastAction altText="Close">Close</ToastAction>,
title: t('dialogs.accountDeleted'),
description: t('dialogs.accountDeletedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
@@ -54,13 +56,13 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
function handleError(error: AxiosError) {
const errorMessage = error.response?.data ||
error.message ||
`Delete failed, please try again later`;
t('dialogs.deleteFailed');
toast({
variant: "destructive",
title: `Account delete Failed`,
title: t('dialogs.accountDeleteFailed'),
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
@@ -89,29 +91,28 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
className='mr-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete Account Permanently
{t('dialogs.deleteAccountPermanently')}
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
You are deleting <span className='font-bold'>{currentRow.email}</span>.
This will permanently remove:
{t('dialogs.youAreDeleting', { email: currentRow.email })}
</p>
<ul className="list-disc pl-6 space-y-1 text-sm text-muted-foreground">
<li>Account credentials and settings</li>
<li>Local cached metadata and sync status</li>
<li>IMAP synchronization data</li>
<li>OAuth tokens and API credentials</li>
<li>{t('dialogs.accountCredentials')}</li>
<li>{t('dialogs.localCachedMetadata')}</li>
<li>{t('dialogs.imapSyncData')}</li>
<li>{t('dialogs.oauthTokens')}</li>
</ul>
<div className="pt-2">
<Label>
Type the account email to confirm:
{t('dialogs.typeEmailToConfirm')}
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Type "${currentRow.email}" to confirm`}
placeholder={t('dialogs.typeToConfirm', { email: currentRow.email })}
className="mt-2"
/>
</Label>
@@ -119,15 +120,15 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
<Alert variant='destructive'>
<IconAlertCircle className="h-4 w-4" />
<AlertTitle>This action cannot be undone!</AlertTitle>
<AlertTitle>{t('dialogs.cannotBeUndone')}</AlertTitle>
<AlertDescription>
All related resources will be permanently erased.
{t('dialogs.allResourcesErased')}
</AlertDescription>
</Alert>
</div>
}
confirmText={
deleteMutation.isPending ? 'Deleting...' : 'Permanently Delete Account'
deleteMutation.isPending ? t('dialogs.deleting') : t('dialogs.permanentlyDeleteAccount')
}
isLoading={deleteMutation.isPending}
destructive

View File

@@ -27,12 +27,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { update_account } from '@/api/account/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
interface DataTableRowActionsProps {
row: Row<AccountModel>
}
export function EnableAction({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
@@ -42,9 +44,9 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
onSuccess: () => {
setOpen(false);
toast({
title: 'Account Updated',
description: `Account has been successfully ${row.original.enabled ? 'disabled' : 'enabled'}.`,
action: <ToastAction altText="Close">Close</ToastAction>,
title: t('accounts.accountUpdated'),
description: t('accounts.accountHasBeenSuccessfully', { action: row.original.enabled ? t('accounts.disabled').toLowerCase() : t('accounts.enabled').toLowerCase() }),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
})
queryClient.invalidateQueries({ queryKey: ['account-list'] })
},
@@ -56,9 +58,9 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
toast({
variant: "destructive",
title: 'Update Failed',
title: t('accounts.accountUpdateFailed'),
description: errorMessage,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
})
}
})
@@ -77,13 +79,13 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={`${row.original.enabled ? 'Disable' : 'Enable'} Account`}
title={row.original.enabled ? t('accounts.disableAccount') : t('accounts.enableAccount')}
desc={
`Are you sure you want to ${row.original.enabled ? 'disable' : 'enable'} this account?` +
(row.original.enabled ? ' This will prevent the account from being used.' : '')
t('accounts.areYouSureYouWantTo', { action: row.original.enabled ? t('accounts.disable').toLowerCase() : t('accounts.enable').toLowerCase() }) +
(row.original.enabled ? ' ' + t('accounts.thisWillPreventTheAccountFromBeingUsed') : '')
}
destructive={row.original.enabled}
confirmText={row.original.enabled ? 'Disable' : 'Enable'}
confirmText={row.original.enabled ? t('accounts.disable') : t('accounts.enable')}
isLoading={updateMutation.isPending}
handleConfirm={handleConfirm}
/>

View File

@@ -16,50 +16,53 @@
// 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 { ProgressMap } from "@/api/account/api";
import { Progress } from "@/components/ui/progress";
import { useTranslation } from "react-i18next";
interface Props {
progressMap: ProgressMap | undefined | null;
progressMap: ProgressMap | undefined | null;
}
export function FolderSyncProgress({ progressMap }: Props) {
const { t } = useTranslation();
if (!progressMap || Object.keys(progressMap).length === 0) {
return <div className="text-muted-foreground text-sm">No Data</div>;
}
if (!progressMap || Object.keys(progressMap).length === 0) {
return <div className="text-muted-foreground text-sm">{t('accounts.folderSync.noData')}</div>;
}
const folderNames = Object.keys(progressMap);
const folderNames = Object.keys(progressMap);
if (folderNames.length === 0) {
return <div className="text-muted-foreground text-sm">No folders to sync</div>;
}
if (folderNames.length === 0) {
return <div className="text-muted-foreground text-sm">{t('accounts.folderSync.noFolders')}</div>;
}
return (
<div className="space-y-4">
{folderNames.map((folder) => {
const progress = progressMap[folder];
const percentage =
progress.total_batches > 0
? Math.min((progress.current_batch / progress.total_batches) * 100, 100)
: 0;
const isComplete = progress.current_batch >= progress.total_batches;
const textColor = isComplete ? "text-green-800" : "text-blue-800";
const progressColor = isComplete ? "bg-gray-200 [&>div]:bg-green-800 [&>div]:rounded-full h-1.5" : "bg-gray-200 [&>div]:bg-blue-800 [&>div]:rounded-full h-1.5";
return (
<div className="space-y-4">
{folderNames.map((folder) => {
const progress = progressMap[folder];
const percentage =
progress.total_batches > 0
? Math.min((progress.current_batch / progress.total_batches) * 100, 100)
: 0;
const isComplete = progress.current_batch >= progress.total_batches;
const textColor = isComplete ? "text-green-800" : "text-blue-800";
const progressColor = isComplete
? "bg-gray-200 [&>div]:bg-green-800 [&>div]:rounded-full h-1.5"
: "bg-gray-200 [&>div]:bg-blue-800 [&>div]:rounded-full h-1.5";
return (
<div key={folder} className="space-y-1">
<div className={`flex justify-between text-sm ${textColor}`}>
<span className="truncate max-w-[70%]">{folder}</span>
<span className="text-sm font-medium">
{progress.current_batch}/{progress.total_batches} batches
</span>
</div>
<Progress value={percentage} className={progressColor} />
</div>
);
})}
</div>
);
return (
<div key={folder} className="space-y-1">
<div className={`flex justify-between text-sm ${textColor}`}>
<span className="truncate max-w-[70%]">{folder}</span>
<span className="text-sm font-medium">
{progress.current_batch}/{progress.total_batches} {t('accounts.folderSync.batches')}
</span>
</div>
<Progress value={percentage} className={progressColor} />
</div>
);
})}
</div>
);
}

View File

@@ -34,12 +34,13 @@ import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2 } from 'lucide-react';
import { AccountModel } from '../data/schema';
import { useTranslation } from 'react-i18next';
const accountSchema = () =>
const accountSchema = (t: (key: string) => string) =>
z.object({
name: z.string().optional(),
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
enabled: z.boolean()
});
@@ -77,13 +78,14 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): NoSyncAccount => {
export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow;
const { toast } = useToast();
const form = useForm<NoSyncAccount>({
mode: "all",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema()),
resolver: zodResolver(accountSchema(t)),
});
const queryClient = useQueryClient();
@@ -102,9 +104,9 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
function handleSuccess() {
toast({
title: `Account ${isEdit ? 'Updated' : 'Created'}`,
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
action: <ToastAction altText="Close">Close</ToastAction>,
title: isEdit ? t('accounts.accountUpdated') : t('accounts.accountCreated'),
description: isEdit ? t('accounts.accountUpdatedDesc') : t('accounts.accountCreatedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
@@ -116,13 +118,13 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
(isEdit ? t('accounts.updateFailed') : t('accounts.creationFailed'));
toast({
variant: "destructive",
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
title: isEdit ? t('accounts.accountUpdateFailed') : t('accounts.accountCreationFailed'),
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
@@ -132,7 +134,8 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const commonData = {
email: data.email,
name: data.name,
enabled: data.enabled
enabled: data.enabled,
use_dangerous: false
};
if (isEdit) {
updateMutation.mutate(commonData);
@@ -156,10 +159,10 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
>
<DialogContent className='max-w-2xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
Click save when you're done.
{isEdit ? t('accounts.updateTheEmailAccountHere') : t('accounts.addNewEmailAccountHere')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
@@ -175,40 +178,40 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Email Address:
{t('accounts.emailAddress')}:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe@gmail.com" {...field} />
<Input placeholder={t('accounts.emailPlaceholder')} {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This account is used for identification purposes only and does not require syncing with an email server. It helps with importing email data.
{t('accounts.thisAccountIsUsedForIdentificationPurposesOnly')}
</FormDescription>
</FormItem>
)}
/>
<FormField
{/* <FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Name:
{t('accounts.name')}:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe" {...field} />
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
</FormControl>
<FormDescription>Optional</FormDescription>
<FormDescription>{t('accounts.optional')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
/> */}
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-col items-start gap-y-1'>
<FormLabel>Enabled:</FormLabel>
<FormLabel>{t('accounts.enabled')}:</FormLabel>
<FormControl>
<Checkbox
checked={field.value}
@@ -216,7 +219,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
/>
</FormControl>
<FormDescription>
Determines whether this account is active. If disabled, the account will not be able to import data or perform queries.
{t('accounts.determinesWhetherThisAccountIsActiveNoSync')}
</FormDescription>
</FormItem>
)}
@@ -234,19 +237,19 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
updateMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
{t('oauth2.saving')}
</>
) : (
"Save changes"
t('accounts.saveChanges')
)
) : (
createMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
{t('oauth2.creating')}
</>
) : (
"Create"
t('common.create')
)
)}
</Button>

View File

@@ -21,17 +21,19 @@ import { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { useTranslation } from 'react-i18next'
interface DataTableRowActionsProps {
row: Row<AccountModel>
}
export function OAuth2Action({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const mailer = row.original
const account_type = mailer.account_type;
if (account_type === "NoSync") {
return <span className="text-xs text-muted-foreground">n/a</span>
return <Button variant={"ghost"} className="text-xs text-muted-foreground">n/a</Button>
}
const isOAuth2 = mailer.imap?.auth.auth_type === "OAuth2"
@@ -52,5 +54,5 @@ export function OAuth2Action({ row }: DataTableRowActionsProps) {
)
}
return <span className="text-xs text-muted-foreground">Password</span>
return <Button variant={"ghost"} className="text-xs text-muted-foreground">{t('settings.password')}</Button>
}

View File

@@ -33,6 +33,7 @@ import { useQuery } from '@tanstack/react-query'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { TableSkeleton } from '@/components/table-skeleton'
import { useTranslation } from 'react-i18next'
import { FileIcon } from 'lucide-react'
import { format, formatDistanceToNow } from 'date-fns'
import LongText from '@/components/long-text'
@@ -41,6 +42,8 @@ import { IconCopy } from '@tabler/icons-react'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { useNavigate } from '@tanstack/react-router'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
interface Props {
currentRow: AccountModel
@@ -49,6 +52,8 @@ interface Props {
}
export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const navigate = useNavigate()
const { data: oauth2Tokens, isLoading } = useQuery({
queryKey: ['oauth2-tokens', currentRow.id],
@@ -65,21 +70,21 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
await navigator.clipboard.writeText(token);
if (access) {
toast({
title: "Success",
description: "Access token copied to clipboard",
title: t('common.ok'),
description: t('accounts.accessTokenCopiedToClipboard'),
});
} else {
toast({
title: "Success",
description: "Refresh token copied to clipboard",
title: t('common.ok'),
description: t('accounts.refreshTokenCopiedToClipboard'),
});
}
} catch (err) {
toast({
variant: "destructive",
title: "Failed to copy text",
title: t('settings.failedToCopyText'),
description: (err as Error).message,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
}
}, []);
@@ -93,9 +98,9 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader className='text-left'>
<DialogTitle>OAuth2 Tokens</DialogTitle>
<DialogTitle>{t('accounts.oauth2Tokens')}</DialogTitle>
<DialogDescription>
Details of the OAuth2 tokens for the account.
{t('accounts.detailsOfTheOAuth2TokensForTheAccount')}
</DialogDescription>
</DialogHeader>
<Card>
@@ -106,20 +111,20 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
<Table className='w-full'>
<TableHeader>
<TableRow>
<TableHead>Field</TableHead>
<TableHead>Value</TableHead>
<TableHead>Action</TableHead>
<TableHead>{t('accounts.field')}</TableHead>
<TableHead>{t('accounts.value')}</TableHead>
<TableHead>{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className='max-w-80'>OAuth2 Name</TableCell>
<TableCell className='max-w-80'>{t('oauth2.id')}</TableCell>
<TableCell>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.oauth2_name}</LongText>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.oauth2_id}</LongText>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Access Token</TableCell>
<TableCell className='max-w-80'>{t('accessTokens.token')}</TableCell>
<TableCell>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.access_token}</LongText>
</TableCell>
@@ -130,7 +135,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Refresh Token</TableCell>
<TableCell className='max-w-80'>{t('accounts.refreshToken')}</TableCell>
<TableCell>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.refresh_token}</LongText>
</TableCell>
@@ -141,15 +146,15 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Created At</TableCell>
<TableCell className='max-w-80'>{t('settings.createdAt')}</TableCell>
<TableCell>
{format(new Date(oauth2Tokens.created_at), 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Updated At</TableCell>
<TableCell className='max-w-80'>{t('settings.updatedAt')}</TableCell>
<TableCell>
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true })}
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true, locale })}
</TableCell>
</TableRow>
</TableBody>
@@ -158,10 +163,11 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
<div className="flex h-[250px] mt-4 shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<FileIcon className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">No OAuth2 Tokens</h3>
<h3 className="mt-4 text-lg font-semibold">{t('accounts.noOAuth2Tokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
The account has not completed the authorization process. Please
<a onClick={() => navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">click here</a> to authorize the account.
{t('accounts.theAccountHasNotCompletedTheAuthorizationProcess')}
<a onClick={() => navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">{t('accounts.clickHere')}</a>
{t('accounts.toAuthorizeTheAccount')}
</p>
</div>
</div>
@@ -170,7 +176,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
</Card>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -21,14 +21,16 @@ import { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { AccountModel } from '../data/schema';
import { useAccountContext } from '../context';
import { useTranslation } from 'react-i18next';
interface Props {
row: Row<AccountModel>
}
export function RunningStateCellAction({ row }: Props) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
let account_type = row.original.account_type;
if (account_type === "NoSync") {
return <span className="text-xs text-muted-foreground">n/a</span>
@@ -39,7 +41,7 @@ export function RunningStateCellAction({ row }: Props) {
setCurrentRow(row.original)
setOpen('running-state')
}}>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">view details</span>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">{t('accounts.viewDetails')}</span>
</Button>
)
}

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/>.
import {
Dialog,
DialogClose,
@@ -34,6 +33,9 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle, Clock, Loader2, PlayCircle, FolderSync, FolderCheck } from 'lucide-react'
import { FolderSyncProgress } from './folder-sync-progress'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
interface Props {
open: boolean
@@ -42,6 +44,8 @@ interface Props {
}
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const { data: state, isLoading } = useQuery({
queryKey: ['running-state', currentRow.id],
queryFn: () => account_state(currentRow.id),
@@ -50,28 +54,27 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
refetchInterval: 5000,
})
// Helper function to calculate duration
const calculateDuration = (start?: number, end?: number) => {
if (!start) {
return (
<span className="text-yellow-600 flex items-center gap-1">
<Clock className="w-4 h-4" /> Not Started
<Clock className="w-4 h-4" /> {t('accounts.runningState.notStarted')}
</span>
);
)
}
if (!end) {
return (
<span className="text-blue-600 flex items-center gap-1">
<PlayCircle className="w-4 h-4" /> In Progress
<PlayCircle className="w-4 h-4" /> {t('accounts.runningState.inProgress')}
</span>
);
)
}
const duration = intervalToDuration({ start: new Date(start), end: new Date(end) })
return (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle className="w-4 h-4" /> {formatDuration(duration, { format: ['hours', 'minutes', 'seconds'] })}
</span>
);
)
}
return (
@@ -105,26 +108,26 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
) : (
<FolderSync className="w-5 h-5 text-blue-500" />
)}
Initial Sync
{t('accounts.runningState.initialSync')}
</h3>
{state.is_initial_sync_completed ? (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
Completed
{t('accounts.runningState.completed')}
</span>
) : (
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
In Progress
{t('accounts.runningState.inProgress')}
</span>
)}
</div>
<div className="grid grid-cols-3 gap-6 w-full">
{/* Start Time */}
<div className="flex flex-col text-sm">
<span className="text-muted-foreground">Start Time:</span>
<span className="text-muted-foreground">{t('accounts.runningState.startTime')}</span>
<span className="font-medium">
{state.initial_sync_start_time ? (
<span className="text-green-600">
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true })}
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true, locale })}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-600">
@@ -132,47 +135,40 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-yellow-500"></span>
</span>
Not Started
{t('accounts.runningState.notStarted')}
</span>
)}
</span>
</div>
{/* End Time */}
<div className="flex flex-col text-sm">
<span className="text-muted-foreground">End Time:</span>
<span className="text-muted-foreground">{t('accounts.runningState.endTime')}</span>
<span className="font-medium">
{state.initial_sync_end_time ? (
<span className="text-green-600">
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true })}
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true, locale })}
</span>
) : state.initial_sync_start_time ? (
<span className="flex items-center gap-1 text-blue-600">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
</span>
In Progress
{t('accounts.runningState.inProgress')}
</span>
) : (
<span className="text-yellow-600">Not Started</span>
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
)}
</span>
</div>
{/* Duration */}
<div className="flex flex-col text-sm">
<span className="text-muted-foreground">Duration:</span>
<span className="text-muted-foreground">{t('accounts.runningState.duration')}</span>
<span className="font-medium">
{(() => {
if (!state.initial_sync_start_time)
return <span className="text-yellow-600">Not Started</span>;
if (!state.initial_sync_end_time)
return <span className="text-blue-600 animate-pulse">Calculating...</span>;
const diff = new Date(state.initial_sync_end_time).getTime() - new Date(state.initial_sync_start_time).getTime();
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
return <span className="text-foreground">{h}h {m}m {s}s</span>;
if (!state.initial_sync_start_time) return <span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
if (!state.initial_sync_end_time) return <span className="text-blue-600 animate-pulse">{t('accounts.runningState.calculating')}</span>
const diff = new Date(state.initial_sync_end_time).getTime() - new Date(state.initial_sync_start_time).getTime()
const h = Math.floor(diff / 3600000)
const m = Math.floor((diff % 3600000) / 60000)
const s = Math.floor((diff % 60000) / 1000)
return <span className="text-foreground">{h}h {m}m {s}s</span>
})()}
</span>
</div>
@@ -182,11 +178,9 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<div className="p-4 border rounded-lg bg-card">
{state && (
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3">Sync Progres</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3">{t('accounts.runningState.syncProgress')}</h3>
<ScrollArea className="h-70 sm:h-70 border rounded-md p-2">
<div className='space-y-4'>
<FolderSyncProgress progressMap={state.progress} />
</div>
<FolderSyncProgress progressMap={state.progress} />
</ScrollArea>
</div>
)}
@@ -194,51 +188,52 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
{/* Incremental Sync */}
<div className="p-4 border rounded-lg bg-card">
<h3 className="text-base sm:text-lg font-semibold mb-3">Incremental Sync</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3">{t('accounts.runningState.incrementalSync')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Start Time:</span>
<span className="text-muted-foreground">{t('accounts.runningState.startTime')}</span>
<span className="font-medium">
{state.last_incremental_sync_start ? (
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true })
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true, locale })
) : (
<span className="text-yellow-600">Not Started</span>
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">End Time:</span>
<span className="text-muted-foreground">{t('accounts.runningState.endTime')}</span>
<span className="font-medium">
{state.last_incremental_sync_end ? (
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true })
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true, locale })
) : state.last_incremental_sync_start ? (
<span className="text-blue-600">In Progress</span>
<span className="text-blue-600">{t('accounts.runningState.inProgress')}</span>
) : (
<span className="text-yellow-600">Not Started</span>
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
)}
</span>
</div>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Duration:</span>
<span className="text-muted-foreground">{t('accounts.runningState.duration')}</span>
<span className="font-medium">
{calculateDuration(state.last_incremental_sync_start, state.last_incremental_sync_end)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sync Interval:</span>
<span className="text-muted-foreground">{t('accounts.runningState.syncInterval')}</span>
<span className="font-medium">
Every {currentRow.sync_interval_min} minutes
{t('accounts.runningState.everyMinutes', { minutes: currentRow.sync_interval_min })}
</span>
</div>
</div>
</div>
</div>
</div>
<div className="p-4 border rounded-lg bg-card">
<h3 className="text-base sm:text-lg font-semibold mb-3">Error Logs</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3">{t('accounts.runningState.errorLogs')}</h3>
<ScrollArea className="h-[20rem] sm:h-[32rem]">
<div className="space-y-3">
{state.errors.length ? (
@@ -251,7 +246,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
>
<div className="flex w-full flex-col gap-1">
<div className="text-xs font-medium text-muted-foreground">
{formatDistanceToNow(new Date(item.at), { addSuffix: true })}
{formatDistanceToNow(new Date(item.at), { addSuffix: true, locale })}
</div>
<div className="font-medium break-words">{item.error}</div>
</div>
@@ -259,7 +254,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
))
) : (
<div className="h-full flex justify-center items-center py-8">
<p className="text-sm text-muted-foreground">No error logs available.</p>
<p className="text-sm text-muted-foreground">{t('accounts.runningState.noErrorLogs')}</p>
</div>
)}
</div>
@@ -270,22 +265,18 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
{!isLoading && !state && (
<div className="h-full flex flex-col justify-center items-center py-8 text-center space-y-2">
<p className="text-sm text-muted-foreground">No active state available</p>
<p className="text-xs text-muted-foreground">
Account synchronization may not have started yet
</p>
<p className="text-sm text-muted-foreground">{t('accounts.runningState.noActiveState')}</p>
<p className="text-xs text-muted-foreground">{t('accounts.runningState.accountNotStarted')}</p>
</div>
)}
</ScrollArea>
<DialogFooter className="pt-4 px-4 sm:px-6 pb-4 border-t">
<DialogClose asChild>
<Button variant="outline" className="w-full sm:w-auto">
Close
</Button>
<Button variant="outline" className="w-full sm:w-auto">{t('accounts.runningState.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
}

View File

@@ -29,19 +29,21 @@ import {
import { Input } from "@/components/ui/input";
import { Account } from "./action-dialog";
import { useTranslation } from "react-i18next";
interface StepProps {
isEdit: boolean;
}
export default function Step1({ isEdit }: StepProps) {
const { t } = useTranslation()
const { control } = useFormContext<Account>();
return (
<>
<h1 className="my-3 md:mt-8">Email Account Registration</h1>
<h1 className="my-3 md:mt-8">{t('accounts.emailAccountRegistration')}</h1>
<p className="mb-5 md:mb-8">
Please provide your email address. In the next steps, you will configure the IMAP/SMTP details. Using this email address, we will attempt to automatically retrieve the SMTP/IMAP server addresses.
{t('accounts.emailAccountRegistrationDesc')}
</p>
<div className="space-y-8">
<FormField
@@ -50,36 +52,20 @@ export default function Step1({ isEdit }: StepProps) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Email Address:
{t('accounts.emailAddress')}:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe@example.com" readOnly={isEdit} {...field} />
<Input placeholder={t('accounts.emailPlaceholder')} readOnly={isEdit} {...field} />
</FormControl>
<FormMessage />
{isEdit && (
<FormDescription>
The email account address cannot be modified when editing.
{t('accounts.emailCannotBeModified')}
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Name:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe" {...field} />
</FormControl>
<FormDescription>Optional</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
);

View File

@@ -37,12 +37,15 @@ import { useFormContext, useWatch } from "react-hook-form";
import { Account } from "./action-dialog";
import { PasswordInput } from "@/components/password-input";
import useProxyList from "@/hooks/use-proxy";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@/components/ui/checkbox";
interface StepProps {
isEdit: boolean;
}
export default function Step2({ isEdit }: StepProps) {
const { t } = useTranslation()
const { control } = useFormContext<Account>();
const { proxyOptions } = useProxyList();
const imapAuthMethod = useWatch({
@@ -59,10 +62,10 @@ export default function Step2({ isEdit }: StepProps) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
IMAP Host:
{t('accounts.imapHost')}:
</FormLabel>
<FormControl>
<Input placeholder="e.g imap.example.com" {...field} />
<Input placeholder={t('accounts.imapHostPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -74,10 +77,10 @@ export default function Step2({ isEdit }: StepProps) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
IMAP Port:
{t('accounts.imapPort')}:
</FormLabel>
<FormControl>
<Input type="number" placeholder="e.g 993" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
<Input type="number" placeholder={t('accounts.imapPortPlaceholder')} {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
@@ -88,11 +91,11 @@ export default function Step2({ isEdit }: StepProps) {
name="imap.encryption"
render={({ field }) => (
<FormItem>
<FormLabel>IMAP Auth Method:</FormLabel>
<FormLabel>{t('accounts.imapEncryption')}:</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an authentication method" />
<SelectValue placeholder={t('accounts.selectEncryptionMethod')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -102,22 +105,55 @@ export default function Step2({ isEdit }: StepProps) {
</SelectContent>
</Select>
<FormDescription>
Choose the authentication method for IMAP.
{t('accounts.chooseEncryptionMethod')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="use_dangerous"
render={({ field }) => (
<FormItem className="flex flex-col items-start gap-y-1">
<FormLabel>{t('accounts.useDangerous')}:</FormLabel>
<FormControl>
<Checkbox
className="mt-2"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('accounts.useDangerousDescription')}</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.name')}:
</FormLabel>
<FormControl>
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
</FormControl>
<FormDescription>{t('accounts.nameDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.auth.auth_type"
render={({ field }) => (
<FormItem>
<FormLabel>IMAP Auth Method:</FormLabel>
<FormLabel>{t('accounts.imapAuthMethod')}:</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an authentication method" />
<SelectValue placeholder={t('accounts.selectAuthMethod')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -126,7 +162,7 @@ export default function Step2({ isEdit }: StepProps) {
</SelectContent>
</Select>
<FormDescription>
Choose the authentication method for IMAP.
{t('accounts.chooseAuthMethod')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -139,15 +175,15 @@ export default function Step2({ isEdit }: StepProps) {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
IMAP Password:
{t('accounts.imapPassword')}:
</FormLabel>
<FormControl>
<PasswordInput placeholder={isEdit ? "Leave empty to keep current password" : "Enter your password"} {...field} />
<PasswordInput placeholder={isEdit ? t('accounts.leaveEmptyToKeepPassword') : t('accounts.enterPassword')} {...field} />
</FormControl>
<FormMessage />
{isEdit && (
<FormDescription>
Leave empty to keep the existing password, or enter a new password to update it.
{t('accounts.leaveEmptyToKeepExisting')}
</FormDescription>
)}
</FormItem>
@@ -159,7 +195,7 @@ export default function Step2({ isEdit }: StepProps) {
name='imap.use_proxy'
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">Use Proxy(optional):</FormLabel>
<FormLabel className="flex items-center justify-between">{t('accounts.useProxy')} ({t('accounts.optional')}):</FormLabel>
<FormControl>
<Select
onValueChange={(val) => field.onChange(Number(val))}
@@ -167,7 +203,7 @@ export default function Step2({ isEdit }: StepProps) {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a proxy" />
<SelectValue placeholder={t('accounts.selectProxy')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -178,13 +214,13 @@ export default function Step2({ isEdit }: StepProps) {
</SelectItem>
))
) : (
<SelectItem disabled value="__none__">No proxy available</SelectItem>
<SelectItem disabled value="__none__">{t('settings.noProxies')}</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription className='flex-1'>
Use a SOCKS5 proxy for IMAP connections.
{t('accounts.imapProxy')}
</FormDescription>
<FormMessage />
</FormItem>

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/>.
import {
FormField,
FormItem,
@@ -44,85 +43,95 @@ import { cn } from "@/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { useTranslation } from "react-i18next";
export default function Step3() {
const { t } = useTranslation();
const { control, getValues, setValue } = useFormContext<Account>();
const current = getValues();
const [rangeType, setRangeType] = useState<'none' | 'fixed' | 'relative'>(current.date_since ? (current.date_since.fixed ? 'fixed' : 'relative') : 'none')
const [rangeType, setRangeType] = useState<'none' | 'fixed' | 'relative'>(
current.date_since ? (current.date_since.fixed ? 'fixed' : 'relative') : 'none'
);
return (
<>
<div className="space-y-8">
<FormField
control={control}
name="sync_interval_min"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Incremental Sync(minutes):
</FormLabel>
<FormControl>
<Input type="number" placeholder="e.g 300" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-col items-start gap-y-1'>
<FormLabel>Enabled:</FormLabel>
<FormControl>
<Checkbox
className='mt-2'
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Determines whether this account is active. If disabled, related syncs will not run.
</FormDescription>
</FormItem>
)}
/>
<FormLabel className="flex items-center justify-between">
Date Since:
</FormLabel>
<RadioGroup
defaultValue={rangeType}
onValueChange={(value: 'fixed' | 'relative' | 'none') => {
setRangeType(value);
if (value === 'none') {
setValue("date_since", undefined, { shouldValidate: true });
}
<div className="space-y-8">
<FormField
control={control}
name="sync_interval_min"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.incrementalSync')}:
</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accounts.incrementalSyncPlaceholder')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
if (value === 'fixed') {
setValue("date_since", { fixed: undefined }, { shouldValidate: true });
}
<FormField
control={control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-col items-start gap-y-1">
<FormLabel>{t('accounts.enabled')}:</FormLabel>
<FormControl>
<Checkbox
className="mt-2"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('accounts.enabledDescription')}</FormDescription>
</FormItem>
)}
/>
<FormLabel className="flex items-center justify-between">{t('accounts.dateSince')}:</FormLabel>
<RadioGroup
defaultValue={rangeType}
onValueChange={(value: 'fixed' | 'relative' | 'none') => {
setRangeType(value);
if (value === 'none') {
setValue("date_since", undefined, { shouldValidate: true });
}
if (value === 'fixed') {
setValue("date_since", { fixed: undefined }, { shouldValidate: true });
}
if (value === 'relative') {
setValue("date_since", { relative: { value: undefined, unit: undefined } }, { shouldValidate: true });
}
}}
className="flex flex-row space-x-4"
>
<FormItem className="flex items-center space-x-3">
<RadioGroupItem value="none" />
<FormLabel className="font-normal">{t('accounts.none')}</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3">
<RadioGroupItem value="fixed" />
<FormLabel className="font-normal">{t('accounts.fixed')}</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3">
<RadioGroupItem value="relative" />
<FormLabel className="font-normal">{t('accounts.relative')}</FormLabel>
</FormItem>
</RadioGroup>
if (value === 'relative') {
setValue("date_since", { relative: { value: undefined, unit: undefined } }, { shouldValidate: true });
}
}}
className='flex flex-row space-x-4'
>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='none' />
<FormLabel className='font-normal'>None</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='fixed' />
<FormLabel className='font-normal'>Fixed</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='relative' />
<FormLabel className='font-normal'>Relative</FormLabel>
</FormItem>
</RadioGroup>
<FormDescription>defines the sync start dateeither specific or relative to now. Preceding emails are excluded,{rangeType === 'fixed' ? " syncs data after a set date" : " shifts the sync date over time, syncing only recent data."}</FormDescription>
{rangeType === 'fixed' && <FormField
<FormDescription>
{t('accounts.syncStartDateDescription', {
fixedPart: rangeType === 'fixed' ? t('accounts.syncAfterDate') : t('accounts.syncRecentData'),
})}
</FormDescription>
{rangeType === 'fixed' && (
<FormField
control={control}
name="date_since.fixed"
render={({ field }) => (
@@ -131,17 +140,13 @@ export default function Step3() {
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
variant="outline"
className={cn(
"w-[240px] pl-3 text-left font-normal text-sm text-brand-marine-blue",
"w-[240px] pl-3 text-left font-normal text-brand-marine-blue",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
{field.value ? format(field.value, "PPP") : <span>{t('accounts.selectDate')}</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
@@ -152,15 +157,13 @@ export default function Step3() {
selected={field.value ? new Date(new Date(field.value).setHours(0, 0, 0, 0)) : undefined}
onSelect={(value) => {
if (value) {
const formattedDate = value.toLocaleDateString('en-CA')
field.onChange(formattedDate)
const formattedDate = value.toLocaleDateString('en-CA');
field.onChange(formattedDate);
} else {
field.onChange(null)
field.onChange(null);
}
}}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
initialFocus
/>
</PopoverContent>
@@ -168,8 +171,11 @@ export default function Step3() {
<FormMessage />
</FormItem>
)}
/>}
{rangeType === 'relative' && <div className="flex flex-row gap-4">
/>
)}
{rangeType === 'relative' && (
<div className="flex flex-row gap-4">
<div className="flex-1">
<FormField
control={control}
@@ -177,7 +183,7 @@ export default function Step3() {
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="number" placeholder="e.g 1" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
<Input type="number" placeholder="e.g. 1" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
@@ -193,13 +199,13 @@ export default function Step3() {
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select unit" />
<SelectValue placeholder={t('accounts.selectUnit')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Days">Days</SelectItem>
<SelectItem value="Months">Months</SelectItem>
<SelectItem value="Years">Years</SelectItem>
<SelectItem value="Days">{t('accounts.days')}</SelectItem>
<SelectItem value="Months">{t('accounts.months')}</SelectItem>
<SelectItem value="Years">{t('accounts.years')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -207,33 +213,28 @@ export default function Step3() {
)}
/>
</div>
</div>}
<FormField
control={control}
name="folder_limit"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Folder Sync Limit:
</FormLabel>
<FormDescription>
Limit the number of emails to sync per folder (minimum 100). Leave empty for no limit.
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="e.g. 1000"
{...field}
onChange={(e) =>
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
</div>
)}
<FormField
control={control}
name="folder_limit"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">{t('accounts.folderLimit')}:</FormLabel>
<FormDescription>{t('accounts.folderLimitDescription')}</FormDescription>
<FormControl>
<Input
type="number"
placeholder={t('accounts.folderLimitPlaceholder')}
{...field}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
}
}

View File

@@ -20,102 +20,89 @@
import { useFormContext } from "react-hook-form";
import { Account } from "./action-dialog";
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
import { useTranslation } from "react-i18next";
export default function Step4() {
const { t } = useTranslation();
const { getValues } = useFormContext<Account>();
const summaryData = getValues();
return (
<>
<div className="p-5 rounded-xl">
<Accordion type="multiple" defaultValue={['email', 'name', 'minimal_sync', 'isolated_index', 'imap', 'smtp', 'date_since', 'folder_limit', 'sync_folders', 'language', 'sync_interval']}>
<AccordionItem key="email" value="email">
<AccordionTrigger className="font-medium capitalize text-gray-600">
Email:
</AccordionTrigger>
<AccordionContent>
{summaryData.email}
</AccordionContent>
</AccordionItem>
<AccordionItem key="name" value="name">
<AccordionTrigger className="font-medium capitalize text-gray-600">
Name:
</AccordionTrigger>
<AccordionContent>
{summaryData.name ?? "n/a"}
</AccordionContent>
</AccordionItem>
<AccordionItem key="imap" value='imap'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Imap:
</AccordionTrigger>
<AccordionContent>
<div className="overflow-x-auto">
<table className="min-w-full divide-y">
<tbody className="divide-y">
<div className="p-5 rounded-xl">
<Accordion type="multiple" defaultValue={['email', 'name', 'imap', 'date_since', 'folder_limit', 'sync_interval']}>
<AccordionItem key="email" value="email">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.email')}:</AccordionTrigger>
<AccordionContent>{summaryData.email}</AccordionContent>
</AccordionItem>
<AccordionItem key="name" value="name">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.name')}:</AccordionTrigger>
<AccordionContent>{summaryData.name ?? t('accounts.notAvailable')}</AccordionContent>
</AccordionItem>
<AccordionItem key="imap" value="imap">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.imap')}:</AccordionTrigger>
<AccordionContent>
<div className="overflow-x-auto">
<table className="min-w-full divide-y">
<tbody className="divide-y">
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.host')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.host}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.port')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.port}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.encryption')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.encryption}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.useDangerous')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{`${summaryData.use_dangerous}`}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.authType')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.auth.auth_type}</td>
</tr>
{summaryData.imap.auth.auth_type === 'Password' && (
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">host:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.host}</td>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.password')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm break-words">{summaryData.imap.auth.password}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">port:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.port}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">encryption:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.encryption}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">auth_type:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.auth.auth_type}</td>
</tr>
{summaryData.imap.auth.auth_type === 'Password' && (
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">password:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm break-words">
{summaryData.imap.auth.password}
</td>
</tr>
)}
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">use proxy:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.use_proxy ? "true" : "false"}</td>
</tr>
</tbody>
</table>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem key="date_since" value='date_since'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Date Selection:
</AccordionTrigger>
<AccordionContent>
{summaryData.date_since?.fixed ?
'since ' + summaryData.date_since.fixed
: summaryData.date_since?.relative && summaryData.date_since.relative.value && summaryData.date_since.relative.unit ?
'recent ' + summaryData.date_since.relative.value + ' ' + summaryData.date_since.relative.unit
: 'n/a'}
</AccordionContent>
</AccordionItem>
<AccordionItem key="folder_limit" value='folder_limit'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Folder Sync Limit:
</AccordionTrigger>
<AccordionContent>
{summaryData.folder_limit ? summaryData.folder_limit : 'n/a'}
</AccordionContent>
</AccordionItem>
<AccordionItem key="sync_interval" value='sync_interval'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Incremental Sync Interval:
</AccordionTrigger>
<AccordionContent>
{summaryData.sync_interval_min} minutes
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</>
)}
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.useProxyField')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.use_proxy ? t('common.yes') : t('common.no')}</td>
</tr>
</tbody>
</table>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem key="date_since" value="date_since">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.dateSelection')}:</AccordionTrigger>
<AccordionContent>
{summaryData.date_since?.fixed
? t('accounts.since') + ' ' + summaryData.date_since.fixed
: summaryData.date_since?.relative && summaryData.date_since.relative.value && summaryData.date_since.relative.unit
? t('accounts.recent') + ' ' + summaryData.date_since.relative.value + ' ' + summaryData.date_since.relative.unit
: t('accounts.notAvailable')}
</AccordionContent>
</AccordionItem>
<AccordionItem key="folder_limit" value="folder_limit">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.folderLimit')}:</AccordionTrigger>
<AccordionContent>{summaryData.folder_limit ?? t('accounts.notAvailable')}</AccordionContent>
</AccordionItem>
<AccordionItem key="sync_interval" value="sync_interval">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.incrementalSync')}:</AccordionTrigger>
<AccordionContent>{summaryData.sync_interval_min} {t('accounts.minutes')}</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
}

View File

@@ -26,18 +26,119 @@ import {
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, CheckSquare, Square } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AccountModel } from '../data/schema'
import { toast } from '@/hooks/use-toast'
import { list_mailboxes } from '@/api/mailbox/api'
import { buildTree } from '@/lib/build-tree'
import { TreeDataItem, TreeView } from '@/components/tree-view'
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree'
import { Skeleton } from '@/components/ui/skeleton'
import { update_account } from '@/api/account/api'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import axios, { AxiosError } from 'axios'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
import { useTheme } from '@/context/theme-context'
import React from 'react'
import Collapse from '@mui/material/Collapse';
import { styled } from '@mui/material/styles';
import { TreeItemCheckbox, TreeItemContent, TreeItemIconContainer, TreeItemLabel, TreeItemRoot } from '@mui/x-tree-view/TreeItem'
import { TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemProvider, TreeViewBaseItem, TreeViewSelectionPropagation, useTreeItem, useTreeItemModel, UseTreeItemParameters } from '@mui/x-tree-view'
import { animated, useSpring } from '@react-spring/web';
import { TransitionProps } from '@mui/material/transitions'
function getParentIds(tree: TreeViewBaseItem[]): string[] {
const result: string[] = [];
function traverse(nodes: TreeViewBaseItem[]) {
for (const node of nodes) {
if (node.children && node.children.length > 0) {
result.push(node.id);
traverse(node.children);
}
}
}
traverse(tree);
return result;
}
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
icon?: React.ElementType;
expandable?: boolean;
}
function CustomLabel({
expandable,
exists,
attributes,
children,
...other
}: CustomLabelProps) {
return (
<TreeItemLabel
{...other}
sx={{
display: 'flex',
alignItems: 'center',
}}
>
<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>
{exists !== undefined && (
<span
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
>
{exists}
</span>
)}
</TreeItemLabel>
);
}
const CustomCollapse = styled(Collapse)({
padding: 0,
});
const AnimatedCollapse = animated(CustomCollapse);
function TransitionComponent(props: TransitionProps) {
const style = useSpring({
to: {
opacity: props.in ? 1 : 0,
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
},
});
return <AnimatedCollapse style={style} {...props} />;
}
interface CustomTreeItemProps
extends Omit<UseTreeItemParameters, 'rootRef'>,
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
interface Props {
open: boolean
@@ -46,36 +147,152 @@ interface Props {
}
export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
const [selectedFolders, setSelectedFolders] = useState<string[]>(currentRow.sync_folders || []);
const [selectedItems, setSelectedItems] = React.useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [allIds, setAllIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const [itemsWithChildren, setItemsWithChildren] = useState<string[]>([]);
const [mailboxes, setMailboxes] = useState<MailboxData[]>([]);
const [selectionPropagation, setSelectionPropagation] =
React.useState<TreeViewSelectionPropagation>({
parents: false,
descendants: true,
});
const [treeData, setTreeData] = useState<TreeViewBaseItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const queryClient = useQueryClient();
const { data: mailboxes, isLoading } = useQuery({
queryKey: ['account-mailboxes', currentRow.id],
queryFn: () => list_mailboxes(currentRow.id, true),
enabled: open,
});
const { t } = useTranslation()
const { theme } = useTheme()
// Convert mailbox names to IDs for initial selection
const initialSelectedItemIds = useMemo(() => {
if (!mailboxes) return [];
return mailboxes
.filter(mailbox => selectedFolders.includes(mailbox.name))
.map(mailbox => mailbox.id.toString());
}, [mailboxes, selectedFolders]);
useEffect(() => {
if (!open) return;
let cancelled = false;
const fetchMailboxes = async () => {
setIsLoading(true);
try {
const data = await list_mailboxes(currentRow.id, true);
if (!cancelled) {
setMailboxes(data);
const allIds = data.map(mailbox => String(mailbox.id));
setAllIds(allIds);
// Convert data to tree structure
const treeData = useMemo(() => {
if (!mailboxes) return [];
return buildTree(mailboxes, undefined, true, true);
}, [mailboxes]);
const tree = buildTree(data);
setTreeData(tree);
const itemsWithChildren = getParentIds(tree);
setItemsWithChildren(itemsWithChildren);
setExpandedItems(itemsWithChildren);
const sync_folders = data
.filter(mailbox => currentRow.sync_folders.includes(mailbox.name))
.map(mailbox => mailbox.id.toString());
setSelectedItems(sync_folders);
setError(undefined);
}
} catch (err: any) {
if (axios.isAxiosError(err)) {
const resData = err.response?.data;
if (resData) {
setError(`Error ${resData.code || ''}: ${resData.message || ''}`);
} else {
setError(err.message);
}
}
} finally {
if (!cancelled) setIsLoading(false);
}
};
fetchMailboxes();
return () => {
cancelled = true;
};
}, [currentRow, open]);
const handleSelectItems = useCallback((selectedItems: TreeDataItem[]) => {
const selected = selectedItems
.map(item => mailboxes?.find(m => m.id === parseInt(item.id, 10))?.name)
.filter(Boolean) as string[];
setSelectedFolders(selected);
}, [mailboxes]);
const handleExpandedItemsChange = (
_event: React.SyntheticEvent | null,
itemIds: string[],
) => {
setExpandedItems(itemIds);
};
const handleExpandClick = () => {
setExpandedItems((oldExpanded) =>
oldExpanded.length === 0 ? itemsWithChildren : [],
);
};
const CustomTreeItem = useMemo(() => {
return React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
ref: React.Ref<HTMLLIElement>,
) {
const { id, itemId, label, disabled, children, ...other } = props;
const {
getContextProviderProps,
getRootProps,
getContentProps,
getIconContainerProps,
getCheckboxProps,
getLabelProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
return (
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)}>
<TreeItemContent {...getContentProps()}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
/>
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
</TreeItemContent>
{children && <TransitionComponent {...getGroupTransitionProps()} />}
</TreeItemRoot>
</TreeItemProvider>
);
});
}, [theme]);
const handleSelectAll = useCallback(() => {
const selectedSet = new Set(allIds);
const selectedAllMailbox = mailboxes.find((mb) =>
selectedSet.has(String(mb.id)) &&
mb.attributes?.some(a => a.attr === 'All')
);
if (selectedAllMailbox) {
toast({
title: t('accounts.allMailFolderSelected'),
description: t('accounts.allMailFolderSelectedDesc'),
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
});
}
setSelectedItems(allIds);
}, [allIds]);
const handleDeselectAll = useCallback(() => {
setSelectedItems([]);
}, []);
const updateMutation = useMutation({
@@ -86,79 +303,188 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
function handleSuccess() {
toast({
title: 'Account Sync Folders Updated',
description: 'Account has been successfully updated.',
action: <ToastAction altText="Close">Close</ToastAction>,
title: t('accounts.accountSyncFoldersUpdated'),
description: t('accounts.accountUpdatedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
setIsSubmitting(false);
onOpenChange(false);
}
function handleError(error: AxiosError) {
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
'Update failed, please try again later';
t('accounts.updateFailed');
toast({
variant: "destructive",
title: 'Account Sync Folders Update Failed',
title: t('accounts.accountSyncFoldersUpdateFailed'),
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
setIsSubmitting(false);
console.error(error);
}
const handleSelectedItemsChange = (
_event: React.SyntheticEvent | null,
newSelectedItems: string[],
) => {
const selectedSet = new Set(newSelectedItems);
const selectedAllMailbox = mailboxes.find((mb) =>
selectedSet.has(String(mb.id)) &&
mb.attributes?.some(a => a.attr === 'All')
);
if (selectedAllMailbox) {
toast({
title: t('accounts.allMailFolderSelected'),
description: t('accounts.allMailFolderSelectedDesc'),
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
});
}
setSelectedItems(newSelectedItems);
};
const handleSubmit = async () => {
if (selectedFolders.length === 0) {
if (selectedItems.length === 0) {
toast({
title: 'Error',
description: 'Please select at least one folder',
title: t('common.error'),
description: t('accounts.selectAtLeastOneFolder'),
variant: 'destructive',
});
return;
}
setIsSubmitting(true);
const selectedNames: string[] = [];
const idSet = new Set(selectedItems);
for (const mailbox of mailboxes) {
if (idSet.has(String(mailbox.id))) {
selectedNames.push(mailbox.name);
}
}
updateMutation.mutate({
sync_folders: selectedFolders,
sync_folders: selectedNames,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Select Sync Folders</DialogTitle>
<DialogTitle>{t('accounts.selectSyncFolders')}</DialogTitle>
<DialogDescription>
Choose folders to sync for {currentRow.email}, Newly added folders will begin downloading during the next sync cycle.
{t('accounts.chooseFoldersToSync', { "email": currentRow.email })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-end">
<div className="flex flex-col pt-2 gap-2">
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
disabled={isLoading || !allIds || allIds.length === 0}
className="h-8"
>
<CheckSquare className="w-4 h-4 mr-2" />
{t('common.selectAll')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeselectAll}
disabled={isLoading || allIds.length === 0}
className="h-8"
>
<Square className="w-4 h-4 mr-2" />
{t('common.deselectAll')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setSelectionPropagation(prev => ({
...prev,
descendants: !prev.descendants,
}))
}
disabled={isLoading}
className="h-8"
>
{selectionPropagation.descendants ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
{t('accounts.folderSync.autoSelectDescendants')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setSelectionPropagation(prev => ({
...prev,
parents: !prev.parents,
}))
}
disabled={isLoading}
className="h-8"
>
{selectionPropagation.parents ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
{t('accounts.folderSync.autoSelectParents')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExpandClick}
disabled={isLoading}>
{expandedItems.length === 0 ? t('accounts.folderSync.expandAll') : t('accounts.folderSync.collapseAll')}
</Button>
</div>
<div className="text-sm text-muted-foreground">
{selectedFolders.length} folder(s) selected
{t('accounts.foldersSelected', { count: selectedItems.length })}
</div>
</div>
{isLoading && (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
)}
{!isLoading && (
<TreeView
data={treeData}
multiple
expandAll
clickRowToSelect={false}
initialSelectedItemIds={initialSelectedItemIds}
onSelectItemsChange={handleSelectItems}
/>
)}
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
{isLoading && (
<div className="p-8 space-y-8">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm font-medium">{t('accounts.folderSync.loadingMailboxFolders')}</span>
</div>
<div className="space-y-2">
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
</div>
)}
{!isLoading && (
<RichTreeView
multiSelect
checkboxSelection
items={treeData}
expandedItems={expandedItems}
onExpandedItemsChange={handleExpandedItemsChange}
selectionPropagation={selectionPropagation}
selectedItems={selectedItems}
onSelectedItemsChange={handleSelectedItemsChange}
slots={{ item: CustomTreeItem }}
/>
)}
{error && (
<div className="mt-auto p-2 text-red-600 text-sm font-medium">
{error}
</div>
)}
</ScrollArea>
</div>
<DialogFooter>
@@ -167,14 +493,14 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || isLoading}
disabled={isSubmitting || isLoading || !!error}
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -44,6 +44,7 @@ import {
import { AccountModel } from '../data/schema'
import { DataTablePagination } from './data-table-pagination'
import { DataTableToolbar } from './data-table-toolbar'
import { useTranslation } from 'react-i18next'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -59,6 +60,7 @@ interface DataTableProps {
export function AccountTable({ columns, data }: DataTableProps) {
const { t } = useTranslation()
const [rowSelection, setRowSelection] = useState({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
@@ -140,7 +142,7 @@ export function AccountTable({ columns, data }: DataTableProps) {
colSpan={columns.length}
className='h-24 text-center'
>
No results.
{t('common.table.noResults')}
</TableCell>
</TableRow>
)}

View File

@@ -60,4 +60,5 @@ export interface AccountModel {
created_at: number;
updated_at: number;
use_proxy?: number
use_dangerous: boolean
}

View File

@@ -22,7 +22,7 @@ import useDialogState from '@/hooks/use-dialog-state'
import { Button } from '@/components/ui/button'
import { Main } from '@/components/layout/main'
import { AccountActionDialog } from './components/action-dialog'
import { columns } from './components/columns'
import { useColumns } from './components/columns'
import { AccountDeleteDialog } from './components/delete-dialog'
import { AccountTable } from './components/table'
import AccountProvider, {
@@ -41,8 +41,11 @@ import { FixedHeader } from '@/components/layout/fixed-header'
import { SyncFoldersDialog } from './components/sync-folders'
import { NoSyncAccountDialog } from './components/nosync-dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
export default function Accounts() {
const { t } = useTranslation()
const columns = useColumns()
// Dialog states
const [currentRow, setCurrentRow] = useState<AccountModel | null>(null)
const [open, setOpen] = useDialogState<AccountDialogType>(null)
@@ -63,9 +66,9 @@ export default function Accounts() {
{/* Header Section */}
<div className='mb-2 flex items-center justify-between flex-wrap gap-x-4 gap-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>Email Accounts</h2>
<h2 className='text-2xl font-bold tracking-tight'>{t('accounts.title')}</h2>
<p className='text-muted-foreground'>
Manage and configure your email accounts.
{t('accounts.description')}
</p>
</div>
<div className="flex gap-2">
@@ -75,7 +78,7 @@ export default function Accounts() {
className="rounded-r-none border-r-0"
>
<Plus className="h-4 w-4" />
Add IMAP
{t('accounts.addImap')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -84,13 +87,13 @@ export default function Accounts() {
className="h-9 w-9 rounded-l-none border-l-0"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More account types</span>
<span className="sr-only">{t('accounts.moreAccountTypes')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOpen("add-nosync")}>
<Plus className="h-4 w-4" />
Add NoSync
{t('accounts.addNoSync')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -110,15 +113,15 @@ export default function Accounts() {
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="RustMailer Logo"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">No Account Configurations</h3>
<h3 className="mt-4 text-lg font-semibold">{t('accounts.noAccountConfigurations')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
You haven't added any Account configurations yet. Add one to start using Account features.
{t('accounts.noAccountConfigurationsDesc')}
</p>
<div className="mt-4 flex flex-col items-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4">
<Button variant="default" className="w-64" onClick={() => setOpen("add-imap")}>
Add Configuration
{t('accounts.addConfiguration')}
</Button>
</div>
</div>

View File

@@ -19,6 +19,7 @@
import { IconLogout } from '@tabler/icons-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
@@ -27,6 +28,7 @@ interface Props {
}
export function LogoutConfirmDialog({ open, onOpenChange, handleConfirm }: Props) {
const { t } = useTranslation()
const handleLogout = () => {
handleConfirm();
onOpenChange(false);
@@ -44,18 +46,18 @@ export function LogoutConfirmDialog({ open, onOpenChange, handleConfirm }: Props
className='mr-1 inline-block stroke-destructive'
size={18}
/>{' '}
Log out
{t('auth.logout')}
</span>
}
desc={
<p>
Are you sure you want to log out?
{t('auth.areYouSureYouWantToLogOut')}
<br />
You will need to log in again to access your account.
{t('auth.youWillNeedToLogInAgain')}
</p>
}
confirmText='Log out'
cancelBtnText='Cancel'
confirmText={t('auth.logout')}
cancelBtnText={t('common.cancel')}
/>
)
}

View File

@@ -40,25 +40,29 @@ import { AxiosError } from 'axios'
import { ToastAction } from '@/components/ui/toast'
import { useLocation, useNavigate } from '@tanstack/react-router'
import { Button } from '@/components/button'
import { useTranslation } from 'react-i18next'
import i18n from '@/i18n'
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
const formSchema = z.object({
const getFormSchema = (t: (key: string, options?: Record<string, any>) => string) => z.object({
username: z
.string(),
password: z
.string()
.min(1, { message: 'Please enter your password' })
.min(4, { message: 'Password must be at least 4 characters long' }),
.min(1, { message: t('validation.pleaseEnterPassword') })
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
});
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState(false)
const navigate = useNavigate()
const { t } = useTranslation()
const { search } = useLocation();
const redirect = new URLSearchParams(search).get('redirect') || '/';
const formSchema = getFormSchema(t)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -82,19 +86,20 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
navigate({ to: redirect });
},
onError: (error) => {
const { t } = i18n
if (error instanceof AxiosError && error.response && error.response.status === 401) {
toast({
variant: "destructive",
title: "Login Failed",
description: "Invalid password. Please try again.",
action: <ToastAction altText="Try again">Try again</ToastAction>,
title: t('auth.loginFailed'),
description: t('auth.invalidPassword'),
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
})
} else {
toast({
variant: "destructive",
title: "Something went wrong",
title: t('auth.somethingWentWrong'),
description: (error as Error).message,
action: <ToastAction altText="Try again">Try again</ToastAction>,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
})
}
setIsLoading(false)
@@ -112,7 +117,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
name='username'
render={({ field }) => (
<FormItem className='space-y-1'>
<FormLabel>Username</FormLabel>
<FormLabel>{t('auth.username')}</FormLabel>
<FormControl>
<Input disabled {...field} value={"root"} />
</FormControl>
@@ -126,7 +131,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
render={({ field }) => (
<FormItem className='space-y-1'>
<div className='flex items-center justify-between'>
<FormLabel>Password</FormLabel>
<FormLabel>{t('auth.password')}</FormLabel>
</div>
<FormControl>
<PasswordInput placeholder='********' {...field} />
@@ -136,7 +141,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
)}
/>
<Button className='mt-2' loading={isLoading}>
Login
{t('auth.login')}
</Button>
</div>
</form>

View File

@@ -19,8 +19,10 @@
import Logo from '@/assets/logo.svg'
import { UserAuthForm } from './components/user-auth-form'
import { useTranslation } from 'react-i18next'
export default function SignIn() {
const { t } = useTranslation()
return (
<div className='container relative flex h-svh flex-col items-center justify-center'>
<div className='p-8 flex flex-col items-center'>
@@ -32,7 +34,7 @@ export default function SignIn() {
alt='Bichon Logo'
/>
<h2 className='mb-4 text-lg font-medium text-muted-foreground'>
Welcome to Bichon
{t('auth.welcome')}
</h2>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 sm:w-[350px]'>
<UserAuthForm />

View File

@@ -10,11 +10,11 @@
//
// 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
// 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/>.
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -22,30 +22,44 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, BarChart, Bar } from 'recharts';
import { Mail, HardDrive, Database, Users, Inbox } from 'lucide-react';
import { Mail, HardDrive, Database, Users, Inbox, Info } from 'lucide-react';
import { formatBytes, formatNumber } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { get_dashboard_stats, TimeBucket } from '@/api/system/api';
import { Main } from '@/components/layout/main';
import { FixedHeader } from '@/components/layout/fixed-header';
import { useTranslation } from 'react-i18next';
interface DailyActivity {
date: string;
count: number;
}
function convertRecentActivity(timeBuckets: TimeBucket[]): DailyActivity[] {
function convertRecentActivity(timeBuckets: TimeBucket[], locale: string): DailyActivity[] {
const dateFormatter = new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
});
return timeBuckets.map(bucket => {
const date = new Date(bucket.timestamp_ms);
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return {
date: `${mm}-${dd}`,
date: dateFormatter.format(date),
count: bucket.count,
timestamp_ms: bucket.timestamp_ms,
};
});
}
const formatTooltipDate = (timestamp_ms: number, locale: string): string => {
const date = new Date(timestamp_ms);
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
};
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
// Skeleton Components
@@ -93,17 +107,18 @@ export default function MailArchiveDashboard() {
queryFn: get_dashboard_stats,
});
// 计算附件比例
const { t, i18n } = useTranslation();
const currentLocale = i18n.resolvedLanguage || i18n.language || navigator.language;
const totalAttachments = (stats?.with_attachment_count ?? 0) + (stats?.without_attachment_count ?? 0);
const attachmentRatio = totalAttachments > 0 ? (stats?.with_attachment_count ?? 0) / totalAttachments : 0;
// 空状态判断
const hasRecentActivity = stats?.recent_activity && stats.recent_activity.length > 0;
const hasTopSenders = stats?.top_senders && stats.top_senders.length > 0;
const hasTopEmails = stats?.top_largest_emails && stats.top_largest_emails.length > 0;
const hasTopAccounts = stats?.top_accounts && stats.top_accounts.length > 0;
// 附件饼图数据(空时显示“无数据”)
const attachmentData = totalAttachments > 0
? [
{ name: 'With Attachments', value: attachmentRatio, fill: COLORS[1] },
@@ -124,8 +139,8 @@ export default function MailArchiveDashboard() {
<Skeleton className="h-7 w-28 rounded-full" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{[...Array(5)].map((_, i) => (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
{[...Array(6)].map((_, i) => (
<MetricCardSkeleton key={i} />
))}
</div>
@@ -151,95 +166,128 @@ export default function MailArchiveDashboard() {
<FixedHeader />
<Main higher>
<div className="flex-1 space-y-6 p-6 md:p-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
<h2 className="text-3xl font-bold tracking-tight">{t('dashboard.title')}</h2>
</div>
</div>
{/* Core Metric Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Mail Accounts</CardTitle>
<CardTitle className="text-sm font-medium">{t('dashboard.mailAccounts')}</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(stats!.account_count)}</div>
<p className="text-xs text-muted-foreground">Connected</p>
<p className="text-xs text-muted-foreground">{t('dashboard.connected')}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Emails</CardTitle>
<CardTitle className="text-sm font-medium">{t('dashboard.totalEmails')}</CardTitle>
<Mail className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(stats!.email_count)}</div>
<p className="text-xs text-muted-foreground">Synced locally</p>
<p className="text-xs text-muted-foreground">{t('dashboard.syncedLocally')}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Email Size</CardTitle>
<CardTitle className="text-sm font-medium">{t('dashboard.totalEmailSize')}</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatBytes(stats!.total_size_bytes)}</div>
<p className="text-xs text-muted-foreground">Logical volume</p>
<p className="text-xs text-muted-foreground">{t('dashboard.logicalVolume')}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Local Data Files</CardTitle>
<CardTitle className="text-sm font-medium">{t('dashboard.localDataFiles')}</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatBytes(stats!.storage_usage_bytes)}</div>
<p className="text-xs text-muted-foreground">Actual disk usage</p>
<p className="text-xs text-muted-foreground">{t('dashboard.actualDiskUsage')}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Index Size</CardTitle>
<CardTitle className="text-sm font-medium">{t('dashboard.indexSize')}</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatBytes(stats!.index_usage_bytes)}</div>
<p className="text-xs text-muted-foreground">Tantivy index</p>
<p className="text-xs text-muted-foreground">{t('dashboard.tantivyIndex')}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('dashboard.systemVersion')}</CardTitle>
<Info className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>
{stats!.system_version ? (
<a
href={`https://github.com/rustmailer/bichon/releases/tag/${stats!.system_version}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{stats!.system_version}
</a>
) : 'N/A'}
</div>
<p className="text-xs text-muted-foreground">
{stats!.commit_hash ?? 'N/A'}
</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="trend" className="space-y-4">
<TabsList className="grid w-full grid-cols-3 lg:w-auto">
<TabsTrigger value="trend">30-Day Trend</TabsTrigger>
<TabsTrigger value="attachment">Attachments</TabsTrigger>
<TabsTrigger value="top">Top Lists</TabsTrigger>
<TabsTrigger value="trend">{t('dashboard.dayTrend')}</TabsTrigger>
<TabsTrigger value="attachment">{t('dashboard.attachments')}</TabsTrigger>
<TabsTrigger value="top">{t('dashboard.topLists')}</TabsTrigger>
</TabsList>
{/* 30-Day Trend */}
<TabsContent value="trend" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>New Emails</CardTitle>
<CardDescription>Message distribution</CardDescription>
<CardTitle>{t('dashboard.newEmails')}</CardTitle>
<CardDescription>{t('dashboard.messageDistribution')}</CardDescription>
</CardHeader>
<CardContent className="h-80">
{hasRecentActivity ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={convertRecentActivity(stats!.recent_activity)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<BarChart data={convertRecentActivity(stats!.recent_activity, currentLocale)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<XAxis dataKey="date" tick={{ fontSize: 12 }} interval="preserveStart" tickCount={10} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
formatter={(v) => formatNumber(v as number)}
content={({ payload }) => {
if (payload && payload.length) {
const dataPoint = payload[0].payload;
const fullDate = formatTooltipDate(dataPoint.timestamp_ms, currentLocale);
return (
<div className="p-2 border rounded-lg shadow-md bg-white dark:bg-gray-800">
<p className="font-semibold text-sm mb-1">{fullDate}</p>
<p className="text-xs">{t('dashboard.emails')}: {formatNumber(dataPoint.count)}</p>
</div>
);
}
return null;
}}
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
@@ -250,7 +298,7 @@ export default function MailArchiveDashboard() {
</BarChart>
</ResponsiveContainer>
) : (
<EmptyChart title="No recent activity" />
<EmptyChart title={t('dashboard.noRecentActivity')} />
)}
</CardContent>
</Card>
@@ -260,11 +308,11 @@ export default function MailArchiveDashboard() {
<TabsContent value="attachment" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Attachment Ratio</CardTitle>
<CardTitle>{t('dashboard.attachmentRatio')}</CardTitle>
<CardDescription>
{totalAttachments > 0
? `${(attachmentRatio * 100).toFixed(1)}% of emails have attachments`
: 'No emails synced yet'}
? t('dashboard.attachmentRatioDesc', { percent: (attachmentRatio * 100).toFixed(1) })
: t('dashboard.noEmailsSynced')}
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-center h-80">
@@ -292,18 +340,18 @@ export default function MailArchiveDashboard() {
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-green-500"></div>
<span className="text-sm">
With Attachments ({(attachmentRatio * 100).toFixed(1)}%)
{t('dashboard.withAttachments')} ({(attachmentRatio * 100).toFixed(1)}%)
</span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-gray-200"></div>
<span className="text-sm">No Attachments</span>
<span className="text-sm">{t('dashboard.noAttachments')}</span>
</div>
</>
) : (
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-gray-200"></div>
<span className="text-sm">No Data</span>
<span className="text-sm">{t('common.noData')}</span>
</div>
)}
</div>
@@ -311,23 +359,20 @@ export default function MailArchiveDashboard() {
</Card>
</TabsContent>
{/* Top Lists */}
<TabsContent value="top" className="space-y-6">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* Top 10 Senders */}
<Card className="md:col-span-1">
<CardHeader>
<CardTitle>Top 10 Senders</CardTitle>
<CardDescription>By message count</CardDescription>
<CardTitle>{t('dashboard.top10Senders')}</CardTitle>
<CardDescription>{t('dashboard.byMessageCount')}</CardDescription>
</CardHeader>
<CardContent>
{hasTopSenders ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Sender</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead>{t('dashboard.sender')}</TableHead>
<TableHead className="text-right">{t('dashboard.count')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -342,24 +387,22 @@ export default function MailArchiveDashboard() {
</TableBody>
</Table>
) : (
<EmptyTable title="No senders data" />
<EmptyTable title={t('dashboard.noSendersData')} />
)}
</CardContent>
</Card>
{/* Top 10 Largest Emails */}
<Card className="md:col-span-1">
<CardHeader>
<CardTitle>Top 10 Largest Emails</CardTitle>
<CardDescription>By message size</CardDescription>
<CardTitle>{t('dashboard.top10LargestEmails')}</CardTitle>
<CardDescription>{t('dashboard.byMessageSize')}</CardDescription>
</CardHeader>
<CardContent>
{hasTopEmails ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Subject</TableHead>
<TableHead className="text-right">Size</TableHead>
<TableHead>{t('dashboard.subject')}</TableHead>
<TableHead className="text-right">{t('dashboard.size')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -369,7 +412,7 @@ export default function MailArchiveDashboard() {
className="max-w-[180px] truncate font-medium"
title={m.subject}
>
{m.subject || '(No Subject)'}
{m.subject || t('dashboard.noSubject')}
</TableCell>
<TableCell className="text-right">{formatBytes(m.size_bytes)}</TableCell>
</TableRow>
@@ -377,24 +420,22 @@ export default function MailArchiveDashboard() {
</TableBody>
</Table>
) : (
<EmptyTable title="No large emails" />
<EmptyTable title={t('dashboard.noLargeEmails')} />
)}
</CardContent>
</Card>
{/* Top 10 Accounts */}
<Card className="md:col-span-1">
<CardHeader>
<CardTitle>Top 10 Accounts</CardTitle>
<CardDescription>By message count</CardDescription>
<CardTitle>{t('dashboard.top10Accounts')}</CardTitle>
<CardDescription>{t('dashboard.byMessageCount')}</CardDescription>
</CardHeader>
<CardContent>
{hasTopAccounts ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead className="text-right">Emails</TableHead>
<TableHead>{t('dashboard.account')}</TableHead>
<TableHead className="text-right">{t('dashboard.emails')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -409,7 +450,7 @@ export default function MailArchiveDashboard() {
</TableBody>
</Table>
) : (
<EmptyTable title="No account data" />
<EmptyTable title={t('dashboard.noAccountData')} />
)}
</CardContent>
</Card>
@@ -417,6 +458,11 @@ export default function MailArchiveDashboard() {
</TabsContent>
</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>
</Main>
</>
);

View File

@@ -19,24 +19,25 @@
import { useNavigate, useRouter } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
import { useTranslation } from 'react-i18next'
export default function ForbiddenError() {
const navigate = useNavigate()
const { history } = useRouter()
const { t } = useTranslation()
return (
<div className='h-svh'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
<h1 className='text-[7rem] font-bold leading-tight'>403</h1>
<span className='font-medium'>Access Forbidden</span>
<span className='font-medium'>{t('errors.forbiddenTitle')}</span>
<p className='text-center text-muted-foreground'>
You don't have necessary permission <br />
to view this resource.
{t('errors.forbiddenDesc')}
</p>
<div className='mt-6 flex gap-4'>
<Button variant='outline' onClick={() => history.go(-1)}>
Go Back
{t('common.goBack')}
</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
<Button onClick={() => navigate({ to: '/' })}>{t('common.backToHome')}</Button>
</div>
</div>
</div>

View File

@@ -20,6 +20,7 @@
import { useNavigate, useRouter } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useTranslation } from 'react-i18next'
interface GeneralErrorProps extends React.HTMLAttributes<HTMLDivElement> {
minimal?: boolean
@@ -31,22 +32,23 @@ export default function GeneralError({
}: GeneralErrorProps) {
const navigate = useNavigate()
const { history } = useRouter()
const { t } = useTranslation()
return (
<div className={cn('h-svh w-full', className)}>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
{!minimal && (
<h1 className='text-[7rem] font-bold leading-tight'>500</h1>
)}
<span className='font-medium'>Oops! Something went wrong {`:')`}</span>
<span className='font-medium'>{t('errors.somethingWentWrong')}</span>
<p className='text-center text-muted-foreground'>
We apologize for the inconvenience. <br /> Please try again later.
{t('errors.somethingWentWrongDesc')}
</p>
{!minimal && (
<div className='mt-6 flex gap-4'>
<Button variant='outline' onClick={() => history.go(-1)}>
Go Back
{t('common.goBack')}
</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
<Button onClick={() => navigate({ to: '/' })}>{t('common.backToHome')}</Button>
</div>
)}
</div>

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