46 Commits
0.2.2 ... 0.3.3

Author SHA1 Message Date
rustmailer
8e25b0da14 bump version to 0.3.3 2026-01-21 01:16:58 +08:00
rustmailer
e1f471b8f4 chore: support bulk restore emails 2026-01-21 01:09:38 +08:00
rustmailer
fcd19b1c9f Fix: storage dir creation logic and permissions issues ( #120, #121) 2026-01-21 00:10:07 +08:00
rustmailer
df1a6f8c5b Update README.md 2026-01-20 10:06:13 +08:00
rustmailer
7d150c2982 bump to 0.3.2 2026-01-20 01:25:53 +08:00
rustmailer
9b49005522 fix: "unknown" sender when importing PST #117 2026-01-20 01:22:06 +08:00
rustmailer
1f4e9f7b06 Update nosync-dialog.tsx 2026-01-20 01:21:42 +08:00
rustmailer
d0e4cac229 i18n 2026-01-20 00:53:09 +08:00
rustmailer
30a8d856c1 update 2026-01-19 22:53:11 +08:00
rustmailer
7240be8c31 feat: Separate Docker config file location and email data storage location #81 2026-01-19 22:53:03 +08:00
rustmailer
0d20a9676a feat(search-ui): optimize search UI 2026-01-19 01:52:07 +08:00
rustmailer
48f8092b5a Merge pull request #113 from ktdd/search-improvements-v1
Search improvements
2026-01-14 14:32:38 +08:00
rustmailer
a64351d409 Merge pull request #112 from ktdd/search-table-v1
Replaced email listing with a table with adjustable columns + other changes
2026-01-14 14:32:24 +08:00
rustmailer
a6dd1d19ff Update README.md 2026-01-14 14:01:05 +08:00
rustmailer
f82b20e2fd chore: Set minimum username length to 3 #106 2026-01-14 01:11:01 +08:00
rustmailer
9841038acb chore: Adjust dark mode brightness and light mode saturation #109 2026-01-14 01:04:10 +08:00
rustmailer
97c3db3bd2 chore: default to binding 0.0.0.0 and support binding IPv6 addresses. 2026-01-14 00:27:31 +08:00
rustmailer
82fb2a02bc Merge pull request #110 from op3/feat/support-listening-on-ipv6
Support listening on IPv6 addresses
2026-01-13 23:59:02 +08:00
rustmailer
c61977ce5c Merge pull request #107 from metlos/no-cap-on-sync-interval
Remove the maximum from the sync_interval_min.
2026-01-13 23:51:14 +08:00
rustmailer
106a08fb7e bump verison to 0.3.1 2026-01-13 23:34:46 +08:00
rustmailer
3419506c2e Update README.md 2026-01-13 23:34:17 +08:00
rustmailer
3b040d0cd6 feat: add support for Outlook PST file import #105 2026-01-13 23:32:18 +08:00
rustmailer
5d3c319a67 fix: skip invalid MBOX files during import 2026-01-13 23:31:45 +08:00
ktdd
ad2a43aa35 Search improvements 2026-01-13 12:48:55 +02:00
ktdd
884ae64fc5 Formatting 2026-01-12 12:22:52 +02:00
ktdd
254de35f0e Replaced email listing with a table with adjustable columns. 2026-01-11 18:15:03 +02:00
Oliver Papst
bc3eba5bf7 feat: change default bind address to :: for dual‑stack support
The socket bound to :: accepts both IPv6 and IPv4 (mapped) connections,
so this change enables IPv6 connectivity in addition to the existing
IPv4 behaviour.
2026-01-10 22:36:31 +01:00
Oliver Papst
4dc99b4a84 feat: Add IPv6 support for bichon_bind_ip configuration
Also try to parse the bind_ip address as std::net::Ipv6Addr to accept
both IPv4 and IPv6 addresses. The TcpListener of poem utilizes
ToSocketAddrs trait, which also supports IPv6.
2026-01-10 22:25:31 +01:00
Lukas Krejci
8ce8b9692d Remove the maximum from the sync_interval_min. 2026-01-09 01:35:58 +01:00
rustmailer
3fb761064d Update README.md 2026-01-08 10:52:30 +08:00
rustmailer
54a0a71c44 fix: Missing permission 'user:manage' #102 2026-01-07 23:01:25 +08:00
rustmailer
b490923e17 refactor(search): search filtering and sorting 2026-01-07 16:40:45 +08:00
rustmailer
4ee44daf0d Merge pull request #103 from ktdd/presets-and-sort
Updated presets and added a 'sort by' feature.
2026-01-07 15:03:17 +08:00
ktdd
7edd7c2e35 Updated presets and added a 'sort by' feature. 2026-01-06 12:33:09 +02:00
rustmailer
0c46432150 fix: Inline attachments are not counted as attachments and are not shown when searching for emails with attachments. 2026-01-06 16:28:55 +08:00
rustmailer
d334a23ca7 chore(ui): add attachment file type icon 2026-01-06 16:27:05 +08:00
rustmailer
1768c1a590 fix: Large empty space at the bottom of the screen #98 2026-01-06 14:36:01 +08:00
rustmailer
147f5b4f55 Update README.md 2026-01-05 22:34:24 +08:00
rustmailer
48312dc83e Update README.md 2026-01-05 22:21:53 +08:00
rustmailer
55e97510c4 fix: Folder limit cannot be empty #97 2026-01-05 21:32:00 +08:00
rustmailer
0bf2003670 chore(release): package bichonctl together with bichon binaries 2026-01-05 18:38:34 +08:00
rustmailer
e56fe5ebea feat(mailbox): support mailbox cleanup #96 2026-01-05 18:27:40 +08:00
rustmailer
c69ada32ef feat(ui): add clickable logo to redirect to homepage #95 2026-01-05 14:31:47 +08:00
rustmailer
3f4b37be17 feat(cli): add interactive email import tool for EML, MBOX, and Thunderbird
- Implement `bichonctl` interactive CLI using `dialoguer`.
- Support recursive EML directory scanning with folder structure preservation.
- Support single MBOX file streaming import.
- Support Thunderbird profile import with automatic `.sbd` hierarchy detection.
- Add batch processing (Base64 encoding & batch API requests) for improved performance.
2026-01-05 14:31:04 +08:00
rustmailer
09375ee11c fix: #94 2026-01-01 20:28:23 +08:00
rustmailer
b2e43b0907 fix: skip default admin role validation when global_roles is None #93 2026-01-01 15:24:51 +08:00
126 changed files with 7788 additions and 1290 deletions

View File

@@ -6,6 +6,7 @@ on:
- '[0-9]+.[0-9]+.[0-9]+'
env:
BINARY_NAME: bichon
BINARY_CTL: bichonctl
permissions:
contents: write
@@ -91,6 +92,7 @@ jobs:
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
run: |
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
strip target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}
- name: Pack artifact (Linux/macOS)
if: matrix.os != 'windows-latest'
@@ -99,7 +101,8 @@ jobs:
mkdir -p release
BINARY="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
cp README.md LICENSE release/
cp "$BINARY" release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_CTL }} release/
tar -czvf "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" -C release .
mv "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" release/
@@ -115,10 +118,13 @@ jobs:
shell: pwsh
run: |
mkdir -p release
$BINARY = "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe"
Copy-Item -Path $BINARY -Destination release/
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" release/
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}.exe" release/
Copy-Item -Path README.md -Destination release/
Copy-Item -Path LICENSE -Destination release/
Compress-Archive -Path release\* -DestinationPath "release/${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.zip" -Force
- name: Upload build artifact

557
Cargo.lock generated
View File

@@ -25,7 +25,18 @@ checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561"
dependencies = [
"aes-soft",
"aesni",
"cipher",
"cipher 0.2.5",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cpufeatures",
]
[[package]]
@@ -35,8 +46,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5278b5fabbb9bd46e24aa69b2fdea62c99088e0a950a9be40e3e0101298f88da"
dependencies = [
"aead",
"aes",
"cipher",
"aes 0.6.0",
"cipher 0.2.5",
"ctr",
"ghash",
"subtle",
@@ -48,7 +59,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072"
dependencies = [
"cipher",
"cipher 0.2.5",
"opaque-debug",
]
@@ -58,7 +69,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce"
dependencies = [
"cipher",
"cipher 0.2.5",
"opaque-debug",
]
@@ -170,6 +181,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arc-swap"
version = "1.7.1"
@@ -424,7 +444,7 @@ dependencies = [
[[package]]
name = "bichon"
version = "0.2.2"
version = "0.3.3"
dependencies = [
"ahash",
"async-imap",
@@ -435,7 +455,11 @@ dependencies = [
"cacache",
"chrono",
"clap",
"codepage-strings",
"compressed-rtf",
"console",
"dashmap",
"dialoguer",
"email_address",
"encoding_rs",
"futures",
@@ -449,6 +473,8 @@ dependencies = [
"itoa",
"lru 0.16.2",
"mail-parser",
"mail-send",
"memmap2 0.9.9",
"mimalloc",
"mime_guess",
"murmur3",
@@ -457,6 +483,7 @@ dependencies = [
"num_cpus",
"oauth2",
"openssl-sys",
"outlook-pst",
"poem",
"poem-derive",
"poem-openapi",
@@ -480,6 +507,7 @@ dependencies = [
"tokio-io-timeout",
"tokio-rustls",
"tokio-socks",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
@@ -620,6 +648,15 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "cacache"
version = "13.1.0"
@@ -732,10 +769,20 @@ dependencies = [
]
[[package]]
name = "clap"
version = "4.5.53"
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
dependencies = [
"clap_builder",
"clap_derive",
@@ -743,9 +790,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.53"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
dependencies = [
"anstream",
"anstyle",
@@ -780,12 +827,42 @@ dependencies = [
"cc",
]
[[package]]
name = "codepage"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
dependencies = [
"encoding_rs",
]
[[package]]
name = "codepage-strings"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96e0dcc0fce1af8fe139537bcd0c2522296a701bf11eb6b143ca88ff8c742523"
dependencies = [
"codepage",
"encoding_rs",
"oem_cp",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "compressed-rtf"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50bd502b5779b9f999a2d69115f7e2fb1662883aa5a0e50e33cff684dae101d9"
dependencies = [
"byteorder",
"thiserror 2.0.17",
]
[[package]]
name = "compression-codecs"
version = "0.4.33"
@@ -815,12 +892,31 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width 0.2.2",
"windows-sys 0.61.2",
]
[[package]]
name = "const_fn"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f8a2ca5ac02d09563609681103aada9e1777d54fc57a5acd7a41404f9c93b6e"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "cookie"
version = "0.14.4"
@@ -830,7 +926,7 @@ dependencies = [
"aes-gcm",
"base64 0.13.1",
"hkdf",
"hmac",
"hmac 0.10.1",
"percent-encoding",
"rand 0.8.5",
"sha2 0.9.9",
@@ -859,6 +955,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcb25d077389e53838a8158c8e99174c5a9d902dee4904320db714f3c653ffba"
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -868,6 +979,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -934,7 +1051,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb4a30d54f7443bf3d6191dcd486aca19e67cb3c49fa7a06a319966346707e7f"
dependencies = [
"cipher",
"cipher 0.2.5",
]
[[package]]
@@ -1058,6 +1175,12 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "deflate64"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
[[package]]
name = "deranged"
version = "0.5.5"
@@ -1068,6 +1191,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "derive_more"
version = "2.0.1"
@@ -1089,6 +1223,18 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "dialoguer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
dependencies = [
"console",
"shell-words",
"tempfile",
"zeroize",
]
[[package]]
name = "digest"
version = "0.9.0"
@@ -1106,6 +1252,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common",
"subtle",
]
[[package]]
@@ -1152,6 +1299,12 @@ dependencies = [
"serde",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1173,6 +1326,18 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "enum-as-inner"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1259,6 +1424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
dependencies = [
"crc32fast",
"libz-rs-sys",
"miniz_oxide",
]
@@ -1664,6 +1830,60 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hickory-proto"
version = "0.26.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a62d7684f766b0f96344be88c023f9b6650039aea09d526b4974cce302eb61b1"
dependencies = [
"async-trait",
"bitflags",
"bytes 1.11.0",
"cfg-if",
"data-encoding",
"enum-as-inner 0.6.1",
"futures-channel",
"futures-io",
"futures-util",
"idna 1.1.0",
"ipnet",
"once_cell",
"rand 0.9.2",
"ring",
"rustls",
"rustls-pki-types",
"thiserror 2.0.17",
"time 0.3.44",
"tinyvec",
"tokio",
"tokio-rustls",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
version = "0.26.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbab5e26a7f82341145ba1fbd1f1858d0490624fcc46270db2d3c4a101f763f4"
dependencies = [
"cfg-if",
"futures-util",
"hickory-proto",
"ipconfig",
"moka",
"once_cell",
"parking_lot",
"rand 0.9.2",
"resolv-conf",
"rustls",
"smallvec",
"thiserror 2.0.17",
"tokio",
"tokio-rustls",
"tracing",
]
[[package]]
name = "hkdf"
version = "0.10.0"
@@ -1671,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51ab2f639c231793c5f6114bdb9bbe50a7dbbfcd7c7c6bd8475dec2d991e964f"
dependencies = [
"digest 0.9.0",
"hmac",
"hmac 0.10.1",
]
[[package]]
@@ -1684,6 +1904,15 @@ dependencies = [
"digest 0.9.0",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "html2text"
version = "0.16.5"
@@ -2045,6 +2274,15 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac"
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "instant"
version = "0.1.13"
@@ -2176,6 +2414,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
[[package]]
name = "libbz2-rs-sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
[[package]]
name = "libc"
version = "0.2.177"
@@ -2208,6 +2452,15 @@ dependencies = [
"libc",
]
[[package]]
name = "libz-rs-sys"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415"
dependencies = [
"zlib-rs",
]
[[package]]
name = "libz-sys"
version = "1.1.23"
@@ -2295,12 +2548,52 @@ version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
[[package]]
name = "lzma-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a"
dependencies = [
"crc",
"sha2 0.10.9",
]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mail-auth"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b7da45f78cc525d3750b623c967ae21c0cd28b2e6a9a2ee4b536a7cce3b21ce"
dependencies = [
"ahash",
"flate2",
"hashify",
"hickory-resolver",
"mail-builder",
"mail-parser",
"quick-xml 0.38.4",
"quick_cache",
"ring",
"rustls-pki-types",
"serde",
"serde_json",
"zip",
]
[[package]]
name = "mail-builder"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b"
dependencies = [
"gethostname",
]
[[package]]
name = "mail-parser"
version = "0.11.1"
@@ -2312,6 +2605,26 @@ dependencies = [
"serde",
]
[[package]]
name = "mail-send"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "114a4e27f3cfaf8918783e8fa4149b820c813b1bedc7755e20e12eff4518331e"
dependencies = [
"base64 0.22.1",
"gethostname",
"mail-auth",
"mail-builder",
"md5",
"rand 0.9.2",
"rustls",
"rustls-pki-types",
"smtp-proto",
"tokio",
"tokio-rustls",
"webpki-roots",
]
[[package]]
name = "markup5ever"
version = "0.36.1"
@@ -2338,6 +2651,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "md5"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
[[package]]
name = "measure_time"
version = "0.9.0"
@@ -2446,6 +2765,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moka"
version = "0.12.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a"
dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"parking_lot",
"portable-atomic",
"smallvec",
"tagptr",
"uuid",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -2655,11 +2991,25 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "oem_cp"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a95602f5a6eec5b15394516448c0b6fc2f2ef4ca2a9ab2951a91a18cec516bf0"
dependencies = [
"ahash",
"lazy_static",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]]
name = "once_cell_polyfill"
@@ -2707,6 +3057,16 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "outlook-pst"
version = "1.1.0"
source = "git+https://github.com/rustmailer/outlook-pst-rs.git?branch=main#c12f3595ee5a6f8407d19497133725a3741570aa"
dependencies = [
"byteorder",
"thiserror 2.0.17",
"tracing",
]
[[package]]
name = "ownedbytes"
version = "0.9.0"
@@ -2745,6 +3105,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.10.7",
"hmac 0.12.1",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2880,7 +3250,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"poem-derive",
"quick-xml",
"quick-xml 0.36.2",
"regex",
"rfc7239",
"rust-embed",
@@ -2930,7 +3300,7 @@ dependencies = [
"num-traits",
"poem",
"poem-openapi-derive",
"quick-xml",
"quick-xml 0.36.2",
"regex",
"serde",
"serde_json",
@@ -3004,6 +3374,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -3089,6 +3465,27 @@ dependencies = [
"serde",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick_cache"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3"
dependencies = [
"ahash",
"equivalent",
"hashbrown 0.16.1",
"parking_lot",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -3700,6 +4097,15 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -3795,6 +4201,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "1.3.0"
@@ -3869,6 +4281,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smtp-proto"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55cc1c74d3b758d7dd1fa4dc4cf694cad2732cac14f304228477c2b0ce6233a"
[[package]]
name = "snafu"
version = "0.8.9"
@@ -4143,6 +4561,12 @@ dependencies = [
"windows 0.61.3",
]
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tantivy"
version = "0.25.0"
@@ -4474,9 +4898,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.48.0"
version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
"bytes 1.11.0",
"libc",
@@ -4557,10 +4981,25 @@ dependencies = [
]
[[package]]
name = "toml_datetime"
version = "0.7.3"
name = "toml"
version = "0.9.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533"
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
@@ -4579,13 +5018,19 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.4"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tower"
version = "0.5.2"
@@ -4737,7 +5182,7 @@ dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"enum-as-inner",
"enum-as-inner 0.5.1",
"futures-channel",
"futures-io",
"futures-util",
@@ -4863,14 +5308,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.7"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna 1.1.0",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
@@ -5693,6 +6139,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "zerotrie"
@@ -5727,12 +6187,57 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "zip"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
dependencies = [
"aes 0.8.4",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.3.4",
"hmac 0.12.1",
"indexmap",
"lzma-rust2",
"memchr",
"pbkdf2",
"ppmd-rust",
"sha1 0.10.6",
"time 0.3.44",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zlib-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
[[package]]
name = "zmij"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3280a1b827474fcd5dbef4b35a674deb52ba5c312363aef9135317df179d81b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"

View File

@@ -1,12 +1,15 @@
[package]
name = "bichon"
version = "0.2.2"
version = "0.3.3"
edition = "2021"
[[bin]]
name = "bichon"
path = "src/main.rs"
[[bin]]
name = "bichonctl"
path = "src/bin/bichonctl.rs"
[features]
default = []
@@ -20,7 +23,7 @@ codegen-units = 1
[dependencies]
chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive", "env"] }
clap = { version = "4.5.54", features = ["derive", "env"] }
mimalloc = "0.1.48"
native_db = "0.8.2"
itertools = "0.14.0"
@@ -38,7 +41,7 @@ poem-openapi = { version = "5.1.16", features = [
ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
tokio = { version = "1.48.0", features = ["full"] }
tokio = { version = "1.49.0", features = ["full"] }
tracing = "0.1.44"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
@@ -68,7 +71,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
timeago = "0.5.0"
ahash = "0.8.12"
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
url = { version = "2.5.7", features = ["serde"] }
url = { version = "2.5.8", features = ["serde"] }
sysinfo = "0.37.2"
num_cpus = "1.17.0"
cacache = { version = "13.1.0", default-features = false, features = [
@@ -108,6 +111,14 @@ tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
itoa = "1.0.17"
html2text = "0.16.5"
bytes = "1.11.0"
dialoguer = "0.12.0"
console = "0.16.2"
toml = "0.9.8"
memmap2 = "0.9.9"
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
compressed-rtf = "1.0.0"
codepage-strings = "1.0.2"
mail-send = "0.5.2"
[dev-dependencies]
#bincode = "1.3.3"
#secret-lib = "1.0.0"

View File

@@ -65,7 +65,7 @@ Built in Rust, it requires no external dependencies and provides fast, efficient
* **Internationalized WebUI** — Frontend available in 18 languages
* **OpenAPI Access** — OpenAPI docs with access-token authentication
* **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions
* **Email Import (EML, MBOX & PST)** — Import existing mail archives via the bichonctl CLI
## 🐾 Why Create Bichon?
@@ -92,7 +92,7 @@ Its not perfect, but I hope it brings you value.
<img width="1909" height="904" alt="image" src="https://github.com/user-attachments/assets/ab4bf6ae-faa6-4b49-ae39-705eb9d4487f" />
<img width="1910" height="910" alt="image" src="https://github.com/user-attachments/assets/bcf9cca2-d690-4e7b-b2c9-c52a31c7b999" />
<img width="1915" height="903" alt="image" src="https://github.com/user-attachments/assets/242817d7-3e12-4cbb-afb0-c5ef7366178d" />
<img width="1920" height="910" alt="image" src="https://github.com/user-attachments/assets/14561b74-ed53-4017-9c5b-a64920ec3526" />
<img width="1910" height="1055" alt="image" src="https://github.com/user-attachments/assets/9bde665e-7717-447f-ad29-f743a32a4dc0" />
<img width="1913" height="909" alt="image" src="https://github.com/user-attachments/assets/6fd54cb0-c86f-4ceb-a955-c81107614fc4" />
<img width="1916" height="814" alt="image" src="https://github.com/user-attachments/assets/6a079d98-ff6c-46f4-9ec6-e76d320bff5d" />
@@ -304,6 +304,20 @@ After logging in, the admin user can manage their profile directly in the WebUI:
⚠️ **Security Notice:**
For security reasons, you should **change the default admin password immediately after the first login**.
## 📦 Import Existing Mail Archives
If you already have existing emails stored as **EML** or **MBOX** files, you can import them into Bichon using the `bichonctl` CLI.
This allows you to:
- Index historical emails
- Perform full-text search immediately
- Manage imported data just like synced IMAP emails
📖 **Full documentation:**
👉 https://github.com/rustmailer/bichon/wiki/Using-Bichonctl-For-Email-Import
## 📖 Documentation
> Under construction. Documentation will be available soon.
@@ -335,13 +349,14 @@ This data is provided solely as a **reference** for real-world usage. We encoura
## Roadmap
- ✓ Multi-user support with account/password login
- System-level roles (admin / user)
- Per-mail-account permissions
* [x] Multi-user support with account/password login
* [x] System-level roles (admin / user)
* [x] Per-mail-account permissions
* [ ] `bichon-cli` command-line tool
* [x] `bichonctl` command-line tool
* Import emails from `eml`, `mbox`, `msg`, `pst`
* [x] Import emails from `eml`, `mbox`, `pst` (Single file)
* [ ] Import emails from `msg`
* [ ] Manual sync controls
@@ -455,9 +470,13 @@ This project is licensed under [AGPLv3](LICENSE).
## 💖 Support & Promotion
If this project has been helpful to you and youd like to support its development, you can consider making a small donation or helping spread the word.
Financial support is optional but deeply appreciated — it helps me dedicate more time and resources to building new features and improving the overall experience.
Bichon is an open-source email platform focused on privacy, local ownership, and long-term stability.
You can also support the project by sharing it with others, writing about your experience, or recommending it within relevant communities. Every bit of visibility helps more people benefit from the tool!
The project is freely available and fully functional for everyone.
Some members of the community choose to support the project financially. This support helps sustain ongoing development and long-term maintenance, while keeping the project independent and user-driven.
Support is always optional. You can also contribute by sharing feedback, reporting issues, or recommending Bichon to others.
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Support%20the%20Project-FFDD00?logo=buy-me-a-coffee)](https://buymeacoffee.com/rustmailer)

2
config.toml Normal file
View File

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

View File

@@ -28,7 +28,7 @@ BICHON_ROOT_DIR=/data/bichon-data
# Enable API access token validation
BICHON_ENABLE_ACCESS_TOKEN=false
# IP address to bind the HTTP and gRPC servers to (default: 0.0.0.0)
# IP address to bind the HTTP servers to (default: 0.0.0.0)
BICHON_BIND_IP=
# Comma-separated list of allowed CORS origins (e.g. https://app.example.com)

95
src/bin/bichonctl.rs Normal file
View File

@@ -0,0 +1,95 @@
use bichon::modules::cli::{
BichonCli, BichonCtlConfig, auth::verify_user_and_get_account, eml::handle_eml_directory_import, mbox::handle_mbox_single_file_import, pst::handle_pst_import, thunderbird::handle_thunderbird_import
};
use clap::Parser;
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use std::fs;
#[tokio::main]
async fn main() {
let cli = BichonCli::parse();
let theme = ColorfulTheme::default();
let config_path = &cli.config;
let mut current_config: Option<BichonCtlConfig> = None;
if config_path.exists() {
if let Ok(content) = fs::read_to_string(config_path) {
if let Ok(config) = toml::from_str::<BichonCtlConfig>(&content) {
println!("{}", style("✔ Existing configuration found:").green());
println!(" Base URL: {}", style(&config.base_url).yellow());
println!(" API Token: {}", style(&config.api_token).yellow());
// Confirm with user
if Confirm::with_theme(&theme)
.with_prompt("Do you want to use this configuration?")
.default(true)
.interact()
.unwrap()
{
current_config = Some(config);
}
}
}
}
let final_config = match current_config {
Some(conf) => conf,
None => {
println!("\n{}", style("Please enter Bichon service details:").bold());
let url: String = Input::with_theme(&theme)
.with_prompt("Bichon Base URL")
.default("http://localhost:15630".into())
.interact_text()
.unwrap();
let token: String = Input::with_theme(&theme)
.with_prompt("API Token")
.interact_text()
.unwrap();
let conf = BichonCtlConfig {
base_url: url,
api_token: token,
};
// 3. Offer to save the new configuration
if Confirm::with_theme(&theme)
.with_prompt("Save this configuration for future use?")
.default(true)
.interact()
.unwrap()
{
let toml_str = toml::to_string(&conf).unwrap();
fs::write(config_path, toml_str).expect("Failed to save config file");
println!("{}", style("Configuration saved successfully!").green());
}
conf
}
};
let target_account_id = verify_user_and_get_account(&final_config, &theme).await;
let import_modes = &[
"EML: Scan directory recursively (Maintains folder structure)",
"MBOX: Single archive file (Stream from one file)",
"Thunderbird: Import from local profile directory",
"PST: Outlook Personal Storage (Single .pst file)",
];
let mode_idx = Select::with_theme(&theme)
.with_prompt("Select import method")
.items(import_modes)
.default(0)
.interact()
.unwrap();
match mode_idx {
0 => handle_eml_directory_import(&final_config, target_account_id, &theme).await,
1 => handle_mbox_single_file_import(&final_config, target_account_id, &theme).await,
2 => handle_thunderbird_import(&final_config, target_account_id, &theme).await,
3 => handle_pst_import(&final_config, target_account_id, &theme).await,
_ => unreachable!(),
}
}

1
src/lib.rs Normal file
View File

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

View File

@@ -16,23 +16,24 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use mimalloc::MiMalloc;
use modules::{
common::rustls::RustMailerTls,
context::{executors::EmailClientExecutors, Initialize},
error::BichonResult,
logger,
rest::start_http_server,
tasks::PeriodicTasks,
use bichon::{
bichon_version,
modules::{
common::rustls::RustMailerTls,
context::{executors::EmailClientExecutors, Initialize},
error::BichonResult,
logger,
rest::start_http_server,
tasks::PeriodicTasks,
},
};
use mimalloc::MiMalloc;
use tracing::info;
use crate::modules::{
use bichon::modules::{
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
};
mod modules;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;

View File

@@ -363,11 +363,13 @@ impl AccountV3 {
list_all_impl(DB_MANAGER.meta_db()).await
}
pub async fn minimal_list() -> BichonResult<Vec<MinimalAccount>> {
pub async fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
let result = list_all_impl(DB_MANAGER.meta_db())
.await?
.into_iter()
//.filter(|a: &AccountModel| a.enabled)
.filter(|account: &AccountModel| {
!only_nosync || matches!(account.account_type, AccountType::NoSync)
})
.map(|account: AccountModel| MinimalAccount {
id: account.id,
email: account.email,
@@ -419,6 +421,12 @@ impl AccountV3 {
new.folder_limit = Some(folder_limit);
}
if let Some(clear_folder_limit) = request.clear_folder_limit {
if clear_folder_limit {
new.folder_limit = None;
}
}
if let Some(name) = &request.name {
if name.trim().is_empty() {
new.name = None;

View File

@@ -37,7 +37,7 @@ pub struct AccountCreateRequest {
pub account_type: AccountType,
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>,
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
#[oai(validator(minimum(value = "10")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
@@ -127,6 +127,7 @@ pub struct AccountUpdateRequest {
/// otherwise sync up to `n` most recent emails (min 10).
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>,
pub clear_folder_limit: Option<bool>,
/// Configuration for selective folder (mailbox/label) synchronization
///
/// - For IMAP/SMTP accounts:
@@ -142,7 +143,7 @@ pub struct AccountUpdateRequest {
/// Modified folders will be automatically synced on the next update.
pub sync_folders: Option<Vec<String>>,
/// Incremental sync interval (seconds)
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
#[oai(validator(minimum(value = "10")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
@@ -166,6 +167,13 @@ impl AccountUpdateRequest {
));
}
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
return Err(raise_error!(
"clear_folder_limit cannot be combined with folder_limit".into(),
ErrorCode::InvalidParameter
));
}
if self.clear_date_range == Some(true)
&& (self.date_since.is_some() || self.date_before.is_some())
{

View File

@@ -16,13 +16,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decode_mailbox_name, encode_mailbox_name,
modules::{
database::{
batch_delete_impl, batch_insert_impl, batch_upsert_impl, filter_by_secondary_key_impl,
manager::DB_MANAGER,
async_find_impl, batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl,
filter_by_secondary_key_impl, manager::DB_MANAGER,
},
error::{code::ErrorCode, BichonResult},
},
@@ -90,25 +89,25 @@ impl MailBox {
// Ok(())
// }
// pub async fn get(id: u64) -> RustMailerResult<MailBox> {
// let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
// Ok(result.ok_or_else(|| {
// raise_error!(
// format!("mailbox {} not found", id),
// ErrorCode::InternalError
// )
// })?)
// }
pub async fn get(id: u64) -> BichonResult<MailBox> {
let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
Ok(result.ok_or_else(|| {
raise_error!(
format!("mailbox {} not found", id),
ErrorCode::InternalError
)
})?)
}
// pub async fn delete(id: u64) -> BichonResult<()> {
// delete_impl(DB_MANAGER.envelope_db(), move |rw| {
// rw.get()
// .primary::<MailBox>(id)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// .ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
// })
// .await
// }
pub async fn delete(id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
rw.get()
.primary::<MailBox>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
})
.await
}
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id)

169
src/modules/cli/auth.rs Normal file
View File

@@ -0,0 +1,169 @@
use std::process;
use console::style;
use dialoguer::{theme::ColorfulTheme, Select};
use reqwest::Client;
use crate::modules::{
account::payload::MinimalAccount,
cli::BichonCtlConfig,
users::{permissions::Permission, view::UserView},
};
pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &ColorfulTheme) -> u64 {
let client = Client::new();
let url = format!("{}/api/v1/current-user", config.base_url);
let response = match client
.get(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.send()
.await
{
Ok(res) => res,
Err(e) => {
eprintln!(
"\n{} {}",
style("✘ Network Error:").red().bold(),
"Could not connect to Bichon service."
);
eprintln!("{} {}", style("Details:").dim(), e);
eprintln!(
"\n{} Please check if the Base URL is correct and the server is running.",
style("Tip:").cyan()
);
process::exit(1);
}
};
if !response.status().is_success() {
let status = response.status();
let error_body = response
.text()
.await
.unwrap_or_else(|_| "No error detail provided".to_string());
eprintln!(
"\n{} Server returned an error (Status: {})",
style("✘ API Error:").red().bold(),
style(status).yellow()
);
if status == 401 {
eprintln!(
"{} Your API Token seems to be invalid or expired.",
style("Context:").dim()
);
} else if status == 404 {
eprintln!(
"{} The endpoint was not found. Please check your Base URL.",
style("Context:").dim()
);
}
eprintln!("{} {}", style("Response:").dim(), error_body);
process::exit(1);
}
let user: UserView = response.json().await.expect("Failed to parse user data");
println!("Welcome, {}!", style(&user.username).cyan());
let account_list_url = format!(
"{}/api/v1/minimal-account-list?only_nosync=true",
config.base_url
);
let acc_response = client
.get(&account_list_url)
.header("Authorization", format!("Bearer {}", config.api_token))
.send()
.await
.expect("Failed to fetch account list");
if !acc_response.status().is_success() {
panic!(
"Failed to retrieve accounts. Status: {}",
acc_response.status()
);
}
let accounts: Vec<MinimalAccount> = acc_response
.json()
.await
.expect("Failed to parse minimal account list");
if accounts.is_empty() {
println!(
"\n{}",
style("Error: No 'nosync' accounts found.").red().bold()
);
println!(
"{}",
style("Mail import is only supported for 'nosync' type accounts.").dim()
);
println!(
"Please create a new {} account in the Bichon web interface first.",
style("Nosync").bold().yellow()
);
process::exit(1);
}
let required_permission = Permission::DATA_IMPORT_BATCH;
let mut selectable_accounts = Vec::new();
let mut options = Vec::new();
for acc in accounts {
let has_permission = if let Some(perms) = user.account_permissions.get(&acc.id) {
perms.iter().any(|p| p == required_permission)
} else {
user.global_permissions
.iter()
.any(|p| p == Permission::DATA_MANAGE_ALL || p == Permission::ROOT)
};
let status_prefix = if has_permission {
style(" [READY] ").green()
} else {
style(" [NO PERMISSION] ").red()
};
options.push(format!(
"{}{} - {}",
status_prefix,
style(&acc.email).bold(),
style(format!("ID: {}", acc.id)).dim()
));
selectable_accounts.push((acc, has_permission));
}
let selection = Select::with_theme(theme)
.with_prompt("Select the target account for import")
.items(&options)
.default(0)
.max_length(10)
.interact()
.unwrap();
let (selected_acc, can_import) = &selectable_accounts[selection];
if !*can_import {
eprintln!(
"\n{} You do not have '{}' permission for account {}.",
style("✘ Permission Denied:").red().bold(),
style(required_permission).yellow(),
style(&selected_acc.email).cyan()
);
eprintln!(
"{} Please contact your administrator to upgrade your role for this account.",
style("Tip:").dim()
);
process::exit(1);
}
println!(
"{} Targeting account: {}",
style("").green(),
style(&selected_acc.email).cyan().bold()
);
selected_acc.id
}

133
src/modules/cli/eml/mod.rs Normal file
View File

@@ -0,0 +1,133 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use console::style;
use dialoguer::{theme::ColorfulTheme, Input};
use mail_parser::MessageParser;
use reqwest::Client;
use crate::{
base64_encode_url_safe,
modules::cli::{sender::send_batch_request, BichonCtlConfig},
};
pub async fn handle_eml_directory_import(
config: &BichonCtlConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let root_str: String = Input::with_theme(theme)
.with_prompt("Enter the ROOT directory to scan for .eml files")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if p.exists() && p.is_dir() {
Ok(())
} else {
Err("Directory not found.")
}
})
.interact_text()
.unwrap();
let root_path = std::path::PathBuf::from(root_str);
let mut tasks: HashMap<String, Vec<PathBuf>> = HashMap::new();
println!(
"{}",
style("🔍 Scanning recursively using std::fs...").dim()
);
if let Err(e) = scan_dir(&root_path, &root_path, &mut tasks) {
eprintln!("Error scanning directory: {}", e);
return;
}
if tasks.is_empty() {
println!("{}", style("No .eml files found.").yellow());
} else {
process_and_upload(config, account_id, tasks).await;
}
}
fn scan_dir(
root: &Path,
current: &Path,
tasks: &mut HashMap<String, Vec<PathBuf>>,
) -> std::io::Result<()> {
if current.is_dir() {
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
scan_dir(root, &path, tasks)?;
} else if path.is_file() {
if path.extension().and_then(|s| s.to_str()) == Some("eml") {
let rel_path = path.strip_prefix(root).unwrap_or(Path::new(""));
let mailbox_name = rel_path
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let folder = if mailbox_name.is_empty() {
"Inbox".to_string()
} else {
mailbox_name
};
tasks.entry(folder).or_insert_with(|| Vec::new()).push(path);
}
}
}
}
Ok(())
}
async fn process_and_upload(
config: &BichonCtlConfig,
account_id: u64,
tasks: HashMap<String, Vec<PathBuf>>,
) {
let client = Client::new();
let batch_size = 50;
for (mailbox, files) in tasks {
println!("\n🚀 Processing mailbox: {}", style(&mailbox).cyan().bold());
let mut current_batch = Vec::new();
for file_path in files {
let body = match fs::read(&file_path) {
Ok(b) => b,
Err(e) => {
eprintln!(
" {} Failed to read file {:?}: {}",
style("").red(),
file_path,
e
);
continue;
}
};
if MessageParser::new().parse(&body).is_some() {
let b64_content = base64_encode_url_safe!(&body);
current_batch.push(b64_content);
if current_batch.len() >= batch_size {
let to_send = current_batch;
current_batch = Vec::with_capacity(batch_size);
send_batch_request(&client, config, account_id, &mailbox, to_send).await;
}
} else {
eprintln!(
" {} Invalid format, skipping: {:?}",
style("").yellow(),
file_path
);
}
}
if !current_batch.is_empty() {
send_batch_request(&client, config, account_id, &mailbox, current_batch).await;
}
}
}

View File

@@ -0,0 +1,43 @@
use std::collections::HashSet;
pub fn determine_folder(labels_raw: &str) -> String {
let mut status_blacklist = HashSet::new();
status_blacklist.insert("Opened");
status_blacklist.insert("Unread");
status_blacklist.insert("Archived");
let all_labels: Vec<&str> = labels_raw
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if all_labels.is_empty() {
return "Unknown".to_string();
}
let filtered: Vec<&str> = all_labels
.iter()
.filter(|&&l| !status_blacklist.contains(l))
.cloned()
.collect();
match filtered.len() {
// Case A: If all labels were status labels, fallback to the first original label
0 => all_labels[0].to_string(),
// Case B: If only one label remains, that's our target destination
1 => filtered[0].to_string(),
// Case C: Multiple labels remain (e.g., ["Inbox", "medium"])
_ => {
// Prioritize custom business labels by excluding generic locations like "Inbox" or "Sent"
let business_label = filtered.iter().find(|&&l| l != "Inbox" && l != "Sent");
match business_label {
// Return the first non-generic label found
Some(label) => label.to_string(),
// If only generic labels remain (e.g., ["Sent", "Inbox"]), pick the first available
None => filtered[0].to_string(),
}
}
}
}

151
src/modules/cli/mbox/mod.rs Normal file
View File

@@ -0,0 +1,151 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::base64_encode_url_safe;
use crate::modules::cli::mbox::gmail::determine_folder;
use crate::modules::cli::mbox::reader::MboxFile;
use crate::modules::cli::sender::send_batch_request;
use crate::modules::cli::BichonCtlConfig;
use console::style;
use dialoguer::{theme::ColorfulTheme, Input};
use dialoguer::{Confirm, Select};
use mail_parser::MessageParser;
use reqwest::Client;
pub mod gmail;
pub mod reader;
pub async fn handle_mbox_single_file_import(
config: &BichonCtlConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let path_str: String = Input::with_theme(theme)
.with_prompt("Enter the path to your SINGLE .mbox file")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if !p.exists() {
return Err("The specified path does not exist.");
}
if !p.is_file() {
return Err("MBOX mode requires a SINGLE file, not a directory.");
}
Ok(())
})
.interact_text()
.unwrap();
let mbox_path = PathBuf::from(path_str);
let options = vec![
"Use labels from mail headers (X-Gmail-Labels)",
"Specify a single target folder for all emails",
];
let selection = Select::with_theme(theme)
.with_prompt("How should we determine the target folder?")
.items(&options)
.default(0)
.interact()
.unwrap();
let target_folder: Option<String> = match selection {
0 => None,
1 => {
let folder: String = Input::with_theme(theme)
.with_prompt("Target folder name")
.default("INBOX".into())
.interact_text()
.unwrap();
Some(folder)
}
_ => unreachable!(),
};
if let Some(ref folder) = target_folder {
println!(
"{}",
style(format!("Mode: Fixed folder ({})", folder)).dim()
);
} else {
println!("{}", style("Mode: Dynamic (header-based)").dim());
}
println!(
"\n{} Ready to process MBOX file: {}",
style("").green(),
style(mbox_path.display()).cyan()
);
if let Ok(meta) = std::fs::metadata(&mbox_path) {
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
println!(
"{}",
style(format!("Processing file: {:.1} MB", size_mb)).dim()
);
}
if Confirm::with_theme(theme)
.with_prompt("Start importing?")
.default(true)
.interact()
.unwrap()
{
run_import(account_id, &mbox_path, config, target_folder).await
}
}
pub async fn run_import(
account_id: u64,
mbox_path: &PathBuf,
config: &BichonCtlConfig,
target_folder: Option<String>,
) {
let client = Client::new();
let mbox = match MboxFile::from_file(mbox_path) {
Ok(mbox) => mbox,
Err(err) => {
println!("Skipping invalid MBOX: {} ({})", mbox_path.display(), err);
return;
}
};
let mut folder_buffers: HashMap<String, Vec<String>> = HashMap::new();
let batch_limit = 50;
println!("Starting import process...");
for e in mbox.iter() {
let body = e.data;
let message = MessageParser::new().parse(body).unwrap();
let folder_name = match target_folder {
Some(ref folder_name) => folder_name.clone(),
None => {
let labels = message
.header("X-Gmail-Labels")
.and_then(|h| h.as_text())
.unwrap_or("Inbox");
determine_folder(labels)
}
};
let b64_eml = base64_encode_url_safe!(&body);
let buffer = folder_buffers
.entry(folder_name.clone())
.or_insert_with(|| Vec::new());
buffer.push(b64_eml);
if buffer.len() >= batch_limit {
let emls_to_send = folder_buffers.remove(&folder_name).unwrap();
send_batch_request(&client, config, account_id, &folder_name, emls_to_send).await;
}
}
for (folder_name, emls) in folder_buffers {
if !emls.is_empty() {
send_batch_request(&client, config, account_id, &folder_name, emls).await;
}
}
println!("{}", style("Import completed successfully!").green().bold());
}

View File

@@ -0,0 +1,208 @@
use memmap2::Mmap;
use std::fs;
use std::io;
use std::path::Path;
pub struct MboxFile {
map: Mmap,
}
impl MboxFile {
pub fn from_file(name: &Path) -> io::Result<Self> {
let file = fs::File::open(name)?;
let metadata = file.metadata()?;
if metadata.len() == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Empty MBOX file",
));
}
let map = unsafe { Mmap::map(&file)? };
Ok(Self { map })
}
pub fn iter(&self) -> MboxReader<'_> {
MboxReader::new(&self.map)
}
}
pub struct Entry<'a> {
pub offset: usize,
pub data: &'a [u8],
}
pub struct MboxReader<'a> {
data: &'a [u8],
len: usize,
scan_pos: usize,
body_start: Option<usize>,
}
impl<'a> MboxReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self {
data,
len: data.len(),
scan_pos: 0,
body_start: None,
}
}
fn is_from_line(&self, i: usize) -> bool {
if i + 5 > self.len {
return false;
}
if i == 0 {
&self.data[0..5] == b"From "
} else {
self.data[i - 1] == b'\n' && &self.data[i..i + 5] == b"From "
}
}
fn skip_from_line(&self, mut i: usize) -> usize {
while i < self.len && self.data[i] != b'\n' {
i += 1;
}
if i < self.len {
i += 1;
}
i
}
}
impl<'a> Iterator for MboxReader<'a> {
type Item = Entry<'a>;
fn next(&mut self) -> Option<Self::Item> {
while self.scan_pos < self.len {
if self.is_from_line(self.scan_pos) {
let from_pos = self.scan_pos;
let body_pos = self.skip_from_line(from_pos);
if let Some(start) = self.body_start {
let entry = Entry {
offset: start,
data: &self.data[start..from_pos],
};
self.body_start = Some(body_pos);
self.scan_pos = body_pos;
return Some(entry);
} else {
self.body_start = Some(body_pos);
self.scan_pos = body_pos;
continue;
}
}
self.scan_pos += 1;
}
if let Some(start) = self.body_start.take() {
return Some(Entry {
offset: start,
data: &self.data[start..self.len],
});
}
None
}
}
#[cfg(test)]
mod tests {
use mail_parser::MessageParser;
use crate::modules::cli::mbox::gmail::determine_folder;
use super::*;
fn collect_entries(data: &[u8]) -> Vec<&[u8]> {
let reader = MboxReader::new(data);
reader.map(|e| e.data).collect()
}
#[test]
fn two_mails() {
let data = b"From a\nmail1\nFrom b\nmail2\n";
let e = collect_entries(data);
assert_eq!(e, vec![b"mail1\n", b"mail2\n"]);
}
#[test]
fn no_trailing_newline() {
let data = b"From a\nmail1";
let e = collect_entries(data);
assert_eq!(e, vec![b"mail1"]);
}
#[test]
fn from_inside_body() {
let data = b"From a\nhello\nFrom is here\nbye\n";
let e = collect_entries(data);
assert_eq!(e.len(), 2);
}
#[test]
fn inline_from_not_separator() {
let data = b"From a\nhello From world\n";
let e = collect_entries(data);
assert_eq!(e.len(), 1);
}
#[test]
fn realistic_mbox() {
let data = b"From a\nH:1\n\nbody1\nFrom b\nH:2\n\nbody2\n";
let e = collect_entries(data);
assert_eq!(e.len(), 2);
}
#[test]
fn empty_body() {
let data = b"From a\nFrom b\nbody\n";
let e = collect_entries(data);
assert_eq!(e[0], b"");
assert_eq!(e[1], b"body\n");
}
#[test]
fn only_from_line() {
let data = b"From a\n";
let e = collect_entries(data);
assert_eq!(e.len(), 1);
assert_eq!(e[0], b"");
}
#[test]
fn windows_newlines() {
let data = b"From a\r\nbody\r\nFrom b\r\nbody2\r\n";
let e = collect_entries(data);
assert_eq!(e.len(), 2);
}
#[test]
fn many_small_mails() {
let mut data = Vec::new();
for _ in 0..1000 {
data.extend_from_slice(b"From a\nx\n");
}
let e = collect_entries(&data);
assert_eq!(e.len(), 1000);
}
#[test]
fn test11() {
let mbox = MboxFile::from_file(Path::new("e:\\test.mbox")).unwrap();
for e in mbox.iter() {
let body = e.data;
let message = MessageParser::new().parse(body).unwrap();
let labels = message.header("X-Gmail-Labels").unwrap().as_text().unwrap();
//println!("offset={} X-Gmail-Labels={:?}", e.offset, labels);
println!(
"X-Gmail-Labels={:?}, determine_folder={}",
labels,
determine_folder(labels)
)
}
}
}

36
src/modules/cli/mod.rs Normal file
View File

@@ -0,0 +1,36 @@
use clap::Parser;
use serde::{Deserialize, Serialize};
use crate::bichon_version;
pub mod auth;
pub mod eml;
pub mod mbox;
pub mod pst;
pub mod sender;
pub mod thunderbird;
#[derive(Parser, Debug)]
#[command(
name = "bichonctl",
author = "rustmailer",
version = bichon_version!(),
about = "A CLI tool to import email data into Bichon service"
)]
pub struct BichonCli {
/// Path to the configuration file
#[arg(
short,
long,
default_value = "config.toml",
value_name = "FILE",
help = "Sets a custom config file"
)]
pub config: std::path::PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BichonCtlConfig {
pub base_url: String,
pub api_token: String,
}

View File

@@ -0,0 +1,45 @@
use compressed_rtf::*;
use outlook_pst::ltp::prop_context::PropertyValue;
pub fn decode_subject(value: &PropertyValue) -> Option<String> {
match value {
PropertyValue::String8(value) => {
let offset = match value.buffer().first() {
Some(1) => 2,
_ => 0,
};
let buffer: Vec<_> = value
.buffer()
.iter()
.skip(offset)
.map(|&b| u16::from(b))
.collect();
Some(String::from_utf16_lossy(&buffer))
}
PropertyValue::Unicode(value) => {
let offset = match value.buffer().first() {
Some(1) => 2,
_ => 0,
};
Some(String::from_utf16_lossy(&value.buffer()[offset..]))
}
_ => None,
}
}
pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option<String> {
match code_page {
20127 => {
let buffer: Vec<_> = buffer.iter().map(|&b| u16::from(b)).collect();
Some(String::from_utf16_lossy(&buffer))
}
_ => {
let coding = codepage_strings::Coding::new(code_page).ok()?;
Some(coding.decode(buffer).ok()?.to_string())
}
}
}
pub fn decode_rtf_compressed(buffer: &[u8]) -> Option<String> {
decompress_rtf(buffer).ok()
}

458
src/modules/cli/pst/mod.rs Normal file
View File

@@ -0,0 +1,458 @@
use chrono::{DateTime, TimeZone, Utc};
use dialoguer::theme::ColorfulTheme;
use dialoguer::Input;
use mail_send::mail_builder::headers::text::Text;
use mail_send::mail_builder::MessageBuilder;
use outlook_pst::ltp::prop_context::PropertyValue;
use crate::base64_encode_url_safe;
use crate::modules::cli::pst::encoding::decode_subject;
use crate::modules::cli::sender::send_batch_request;
use crate::modules::cli::BichonCtlConfig;
use dialoguer::Confirm;
use outlook_pst::messaging::attachment::AttachmentProperties;
use outlook_pst::messaging::folder::Folder;
use outlook_pst::messaging::message::{Message, MessageProperties};
use outlook_pst::ndb::node_id::NodeId;
use reqwest::Client;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::rc::Rc;
mod encoding;
#[derive(Debug, Default)]
pub struct EmailMetadata {
pub message_id: Option<String>,
pub subject: Option<String>,
pub from: Option<String>,
pub to: Option<Vec<String>>,
pub cc: Option<Vec<String>>,
pub bcc: Option<Vec<String>>,
pub html: Option<String>,
pub text: Option<String>,
pub in_reply_to: Option<String>,
}
#[derive(Debug, Default)]
pub struct EmailAttachment {
pub name: Option<String>,
pub mime_type: Option<String>,
pub data: Option<Vec<u8>>,
}
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) {
let path_str: String = Input::with_theme(theme)
.with_prompt("Enter the path to your SINGLE .pst file")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if !p.exists() {
return Err("The specified path does not exist.");
}
if !p.is_file() {
return Err("PST mode requires a SINGLE file, not a directory.");
}
let is_pst = p
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("pst"))
.unwrap_or(false);
if !is_pst {
return Err("The selected file must have a .pst extension.");
}
Ok(())
})
.interact_text()
.unwrap();
let pst_path = std::path::PathBuf::from(path_str);
println!(
"\n{} Ready to process PST file: {}",
console::style("").green(),
console::style(pst_path.display()).cyan()
);
if let Ok(meta) = std::fs::metadata(&pst_path) {
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
println!(
"{}",
console::style(format!("PST File Size: {:.1} MB", size_mb)).dim()
);
}
if Confirm::with_theme(theme)
.with_prompt("Start importing emails from this PST?")
.default(true)
.interact()
.unwrap()
{
parse_pst(pst_path, config, account_id).await;
} else {
println!("{}", console::style("Operation cancelled by user.").red());
}
}
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) {
let client = Client::new();
let pst_store = match outlook_pst::open_store(&pst_path) {
Ok(store) => store,
Err(e) => {
println!(
"{} Failed to open PST file: {}",
console::style("").red(),
console::style(format!("{:#?}", e)).dim()
);
return;
}
};
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
Ok(id) => id,
Err(e) => {
println!(
"{} Could not find IPM_SUBTREE (Mailbox Root): {}",
console::style("").red(),
console::style(format!("{:#?}", e)).dim()
);
return;
}
};
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
Ok(folder) => folder,
Err(e) => {
println!(
"{} Failed to open the root mailbox folder: {}",
console::style("").red(),
console::style(format!("{:#?}", e)).dim()
);
return;
}
};
process_folder_recursively(&client, &ipm_subtree_folder, "", config, account_id).await;
}
fn process_folder_recursively<'a>(
client: &'a Client,
folder: &'a Rc<dyn Folder>,
parent_path: &'a str,
config: &'a BichonCtlConfig,
account_id: u64,
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
Box::pin(async move {
let folder_name = folder
.properties()
.display_name()
.unwrap_or_else(|_| "Unknown".to_string());
let current_path = if parent_path.is_empty() {
folder_name
} else {
format!("{}/{}", parent_path, folder_name)
};
println!(
"{} {}",
console::style("📁 Folder:").dim(),
console::style(&current_path).cyan()
);
let mut emls_batch = Vec::new();
if let Some(contents_table) = folder.contents_table() {
for row in contents_table.rows_matrix() {
let store = folder.store().clone();
let entry_id = match store
.properties()
.make_entry_id(NodeId::from(u32::from(row.id())))
{
Ok(id) => id,
Err(e) => {
eprintln!(
" {} Skip row {}: {:?}",
console::style("").yellow(),
row.unique(),
e
);
continue;
}
};
match store.open_message(&entry_id, None) {
Ok(message) => match build_eml_base64(message) {
Some(base64_eml) => emls_batch.push(base64_eml),
None => {}
},
Err(e) => eprintln!(" {} Open error: {:?}", console::style("").yellow(), e),
}
if emls_batch.len() >= 50 {
let batch = emls_batch.clone();
emls_batch.clear();
send_to_bichon(client, config, account_id, &current_path, batch).await;
}
}
}
if !emls_batch.is_empty() {
send_to_bichon(client, config, account_id, &current_path, emls_batch).await;
}
if let Some(hierarchy_table) = folder.hierarchy_table() {
for row in hierarchy_table.rows_matrix() {
let node = NodeId::from(u32::from(row.id()));
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
process_folder_recursively(
client,
&sub_folder,
&current_path,
config,
account_id,
)
.await;
}
}
}
}
})
}
fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
let properties = message.properties();
let mut builder = MessageBuilder::new();
if let Some(sub) = extract_subject(properties) {
builder = builder.subject(sub);
}
if let Some(mid) = extract_string_property(properties, 0x1035) {
builder = builder.message_id(mid);
}
if let Some(irt) = extract_string_property(properties, 0x1042) {
builder = builder.in_reply_to(irt);
}
if let Some(refs) = extract_string_property(properties, 0x1039) {
builder = builder.header("References", Text::new(refs));
}
if let Some(cid_val) = properties.get(0x3013) {
if let PropertyValue::Binary(bin) = cid_val {
builder = builder.header(
"X-Bichon-Conversation-ID",
Text::new(hex::encode(bin.buffer())),
);
}
}
let from = extract_string_property(properties, 0x5D01)
.or_else(|| extract_string_property(properties, 0x5D02))
.or_else(|| extract_string_property(properties, 0x0C1F));
if let Some(f) = from {
builder = builder.from(f);
}
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
let dt = filetime_to_datetime(filetime).timestamp();
builder = builder.date(dt);
}
let (to, cc, bcc) = extract_recipients_list(&message);
if !to.is_empty() {
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !cc.is_empty() {
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !bcc.is_empty() {
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if let Some(html) = extract_html(properties) {
builder = builder.html_body(html);
}
if let Some(text) = extract_text(properties) {
builder = builder.text_body(text);
}
if let Some(attachment_table) = message.attachment_table() {
for row in attachment_table.rows_matrix() {
let node_id = NodeId::from(u32::from(row.id()));
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
let att_props = attachment.properties();
let name = extract_attachment_string_property(att_props, 0x3707);
let mime = extract_attachment_string_property(att_props, 0x370E)
.unwrap_or_else(|| "application/octet-stream".into());
let cid = extract_attachment_string_property(att_props, 0x3712);
let is_inline = att_props
.get(0x3714)
.and_then(|val| {
if let PropertyValue::Integer32(f) = val {
Some(f)
} else {
None
}
})
.map(|flag| (flag & 0x4) != 0)
.unwrap_or(false);
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
let data = bin.buffer().to_vec();
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
if is_inline && cid.is_some() {
let content_id = cid.unwrap();
builder = builder.inline(mime, content_id, data);
} else {
builder = builder.attachment(mime, file_name, data);
}
}
}
}
}
match builder.write_to_vec() {
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
Err(e) => {
eprintln!("Failed to generate EML: {:?}", e);
None
}
}
}
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
let nsecs = (filetime % 10_000_000) * 100;
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
}
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut to = Vec::new();
let mut cc = Vec::new();
let mut bcc = Vec::new();
let recipient_table = message.recipient_table();
let context = recipient_table.context();
for row in recipient_table.rows_matrix() {
if let Ok(cols) = row.columns(context) {
let mut r_type = 0;
let mut email = String::new();
for (col, val) in context.columns().iter().zip(cols) {
let prop_val = val
.as_ref()
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
match col.prop_id() {
0x0C15 => {
if let Some(PropertyValue::Integer32(t)) = prop_val {
r_type = t;
}
}
0x39FE | 0x3003 => {
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
email = s;
}
}
_ => {}
}
}
if !email.is_empty() {
match r_type {
1 => to.push(email),
2 => cc.push(email),
3 => bcc.push(email),
_ => {}
}
}
}
}
(to, cc, bcc)
}
async fn send_to_bichon(
client: &Client,
config: &BichonCtlConfig,
account_id: u64,
folder_path: &str,
emls: Vec<String>,
) {
send_batch_request(client, config, account_id, folder_path, emls).await;
}
fn extract_subject(props: &MessageProperties) -> Option<String> {
props.get(0x0037).and_then(|val| decode_subject(val))
}
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_attachment_string_property(
properties: &AttachmentProperties,
prop_id: u16,
) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_string(value: &PropertyValue) -> Option<String> {
match value {
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
}
}
fn extract_text(properties: &MessageProperties) -> Option<String> {
properties.get(0x1000).and_then(extract_string).or_else(|| {
properties.get(0x1009).and_then(|value| match value {
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
_ => None,
})
})
}
fn extract_html(properties: &MessageProperties) -> Option<String> {
properties.get(0x1013).and_then(|value| match value {
PropertyValue::Binary(value) => {
let code_page = properties
.get(0x3FDE)
.and_then(|v| {
if let PropertyValue::Integer32(cpid) = v {
Some(*cpid as u16)
} else {
None
}
})
.unwrap_or(65001);
encoding::decode_html_body(value.buffer(), code_page)
}
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
})
}
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
for &prop_id in prop_ids {
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
return Some(*value);
}
}
None
}

54
src/modules/cli/sender.rs Normal file
View File

@@ -0,0 +1,54 @@
use console::style;
use reqwest::Client;
use crate::modules::{cli::BichonCtlConfig, import::BatchEmlRequest};
pub async fn send_batch_request(
client: &Client,
config: &BichonCtlConfig,
account_id: u64,
folder: &str,
emls: Vec<String>,
) {
let url = format!("{}/api/v1/import", config.base_url);
let payload = BatchEmlRequest {
account_id,
mail_folder: folder.to_string(),
emls,
};
let count = payload.emls.len();
match client
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.json(&payload)
.send()
.await
{
Ok(res) if res.status().is_success() => {
println!(
" {} Sent {} emails to [{}]",
style("").green(),
count,
folder
);
}
Ok(res) => {
eprintln!(
" {} Failed to send to [{}]. Status: {}",
style("").red(),
folder,
res.status()
);
}
Err(e) => {
eprintln!(
" {} Network error on [{}]: {}",
style("").red(),
folder,
e
);
}
}
}

View File

@@ -0,0 +1,113 @@
use std::{collections::HashMap, path::PathBuf};
use crate::modules::cli::{mbox::run_import, BichonCtlConfig};
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
pub async fn handle_thunderbird_import(
config: &BichonCtlConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let root_str: String = Input::with_theme(theme)
.with_prompt("Enter your Thunderbird Mail/ImapMail directory")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if p.exists() && p.is_dir() {
Ok(())
} else {
Err("Directory not found.")
}
})
.interact_text()
.unwrap();
let root_path = std::path::PathBuf::from(&root_str);
println!("{}", style("🔍 Scanning Thunderbird structure...").dim());
let mut mbox_tasks: HashMap<String, PathBuf> = HashMap::new();
fn scan_thunderbird_dir(
root: &std::path::Path,
current: &std::path::Path,
tasks: &mut HashMap<String, PathBuf>,
) {
if let Ok(entries) = std::fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if path.is_dir() {
scan_thunderbird_dir(root, &path, tasks);
} else {
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
match extension {
"msf" | "dat" | "html" | "json" | "txt" | "sqlite" => continue,
_ => {}
}
if file_name == "filterlog.html" || file_name == "msgFilterRules.dat" {
continue;
}
if !extension.is_empty() {
continue;
}
if let Ok(rel) = path.strip_prefix(root) {
let mailbox = rel.to_string_lossy().replace(".sbd", "").replace('\\', "/");
tasks.insert(mailbox, path);
}
}
}
}
}
scan_thunderbird_dir(&root_path, &root_path, &mut mbox_tasks);
if mbox_tasks.is_empty() {
println!(
"{}",
style("No mailboxes found in the specified directory.").yellow()
);
return;
}
println!("\n{}", style("🔍 Scanned Mailboxes:").bold().underlined());
let mut sorted_keys: Vec<_> = mbox_tasks.keys().collect();
sorted_keys.sort();
for name in &sorted_keys {
let path = &mbox_tasks[*name];
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let size_mb = file_size as f64 / 1024.0 / 1024.0;
println!(
" {} {} ({:.2} MB)",
style("").dim(),
style(name).cyan(),
size_mb
);
}
println!();
let prompt = format!("Ready to import {} mailboxes. Proceed?", mbox_tasks.len());
if Confirm::with_theme(theme)
.with_prompt(prompt)
.default(true)
.interact()
.unwrap()
{
for (mailbox_name, mbox_file) in mbox_tasks {
println!("\n🚀 Importing: {}", style(&mailbox_name).cyan().bold());
run_import(account_id, &mbox_file, config, Some(mailbox_name)).await;
}
println!(
"\n{}",
style("✨ All mailboxes imported successfully!")
.green()
.bold()
);
} else {
println!("{}", style("Import cancelled.").yellow());
}
}

View File

@@ -16,13 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::BichonResult;
pub mod controller;
pub mod executors;
pub mod status;
#[allow(async_fn_in_trait)]
pub trait Initialize {
async fn initialize() -> BichonResult<()>;
}

View File

@@ -100,6 +100,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
let attachments: Vec<String> = message
.attachments()
.filter(|att| {
let disp = att.content_disposition();
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
let has_filename = att.attachment_name().is_some();
has_filename && !is_inline
})
.filter_map(|att| att.attachment_name())
.map(|name| name.to_string())
.collect();
@@ -195,6 +201,12 @@ pub fn extract_envelope_from_eml(
let attachments: Vec<String> = message
.attachments()
.filter(|att| {
let disp = att.content_disposition();
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
let has_filename = att.attachment_name().is_some();
has_filename && !is_inline
})
.filter_map(|att| att.attachment_name())
.map(|name| name.to_string())
.collect();

View File

@@ -100,7 +100,6 @@ impl ImportEmls {
let total = request.emls.len();
for (index, eml_base64) in request.emls.into_iter().enumerate() {
// 1. Decode Base64
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
Ok(bytes) => bytes,
Err(e) => {

View File

@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashSet;
use crate::modules::account::migration::AccountModel;
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::error::code::ErrorCode;
@@ -155,9 +157,9 @@ impl Envelope {
let id = create_hash(account_id, &message_id);
let full_text = extract_string_field(doc, fields.f_text)?;
// Take up to the first 120 characters as a preview;
let preview = if full_text.chars().count() > 120 {
full_text.chars().take(120).collect::<String>() + "..."
// Take up to the first 500 characters as a preview;
let preview = if full_text.chars().count() > 500 {
full_text.chars().take(500).collect::<String>() + "..."
} else {
full_text
};
@@ -204,3 +206,28 @@ impl Envelope {
Ok(envelope)
}
}
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
let fields = SchemaTools::envelope_fields();
let mut all_contacts = HashSet::new();
if let Ok(from_val) = extract_string_field(doc, fields.f_from) {
if !from_val.is_empty() {
all_contacts.insert(from_val);
}
}
let multi_fields = [fields.f_to, fields.f_cc, fields.f_bcc];
for field in multi_fields {
if let Ok(vals) = extract_vec_string_field(doc, field) {
for v in vals {
if !v.is_empty() {
all_contacts.insert(v);
}
}
}
}
Ok(all_contacts)
}

View File

@@ -24,7 +24,10 @@ use std::{
time::Duration,
};
use crate::modules::message::tags::TagCount;
use crate::modules::{
indexer::envelope::extract_contacts,
message::{search::SortBy, tags::TagCount},
};
use crate::{
modules::{
account::migration::AccountModel,
@@ -57,7 +60,10 @@ use tantivy::{
AggregationCollector, Key,
},
collector::{Count, FacetCollector, TopDocs},
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
query::{
AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery,
TermQuery,
},
schema::{Facet, IndexRecordOption, Value},
store::{Compressor, ZstdCompressor},
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
@@ -70,6 +76,7 @@ use tokio::{
sync::{mpsc, Mutex},
task,
};
use tracing::info;
pub static ENVELOPE_INDEX_MANAGER: LazyLock<EnvelopeIndexManager> =
LazyLock::new(EnvelopeIndexManager::new);
@@ -182,13 +189,23 @@ impl EnvelopeIndexManager {
}
fn open_or_create_index(index_dir: &PathBuf) -> Index {
if !index_dir.exists() {
let need_create = !index_dir.exists()
|| index_dir
.read_dir()
.map(|mut d| d.next().is_none())
.unwrap_or(true);
if need_create {
info!(
"Email index not found or empty, creating new index at {}",
index_dir.display()
);
std::fs::create_dir_all(&index_dir).unwrap_or_else(|e| {
panic!("Failed to create index directory {:?}: {}", index_dir, e)
});
Index::create_in_dir(&index_dir, SchemaTools::envelope_schema())
.unwrap_or_else(|e| panic!("Failed to create index in {:?}: {}", index_dir, e))
} else {
info!("Opening existing email index at {}", index_dir.display());
open(&index_dir)
}
}
@@ -306,11 +323,9 @@ impl EnvelopeIndexManager {
(f.f_bcc, &filter.bcc),
] {
if let Some(ref v) = opt_value {
let term = Term::from_field_text(field, v);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) {
subqueries.push((Occur::Must, Box::new(query)));
}
}
}
@@ -327,11 +342,9 @@ impl EnvelopeIndexManager {
}
if let Some(ref name) = filter.attachment_name {
let term = Term::from_field_text(f.f_attachments, name);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) {
subqueries.push((Occur::Must, Box::new(query)));
}
}
let start_bound = if let Some(from) = filter.since {
@@ -351,20 +364,28 @@ impl EnvelopeIndexManager {
subqueries.push((Occur::Must, Box::new(q)));
}
if let Some(account_id) = filter.account_id {
let term = Term::from_field_u64(f.f_account_id, account_id);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
if let Some(account_ids) = filter.account_ids {
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for id in account_ids {
let term = Term::from_field_u64(f.f_account_id, id);
should_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
}
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
}
if let Some(mailbox_id) = filter.mailbox_id {
let term = Term::from_field_u64(f.f_mailbox_id, mailbox_id);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
if let Some(mailbox_ids) = filter.mailbox_ids {
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for id in mailbox_ids {
let term = Term::from_field_u64(f.f_mailbox_id, id);
should_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
}
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
}
let start_bound = if let Some(from) = filter.min_size {
@@ -520,6 +541,48 @@ impl EnvelopeIndexManager {
Ok(all_facets)
}
pub async fn get_all_contacts(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<HashSet<String>> {
let searcher = self.create_searcher()?;
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mut contacts_set: HashSet<String> = HashSet::new();
let top_docs = searcher
.search(&query, &TopDocs::with_limit(1_000_000))
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for (_score, doc_address) in top_docs {
let doc: TantivyDocument = searcher
.doc_async(doc_address)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let contacts = extract_contacts(&doc).await?;
for value in contacts {
contacts_set.insert(value);
}
}
Ok(contacts_set)
}
pub async fn delete_envelopes_multi_account(
&self,
deletes: &HashMap<u64, Vec<u64>>, // HashMap<account_id, envelope_ids>
@@ -621,6 +684,7 @@ impl EnvelopeIndexManager {
page: u64,
page_size: u64,
desc: bool,
sort_by: SortBy,
) -> BichonResult<DataPage<Envelope>> {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
@@ -653,17 +717,36 @@ impl EnvelopeIndexManager {
}
let order = if desc { Order::Desc } else { Order::Asc };
let mailbox_docs: Vec<(i64, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mailbox_docs: Vec<DocAddress>;
match sort_by {
SortBy::DATE => {
let date_docs: Vec<(i64, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
mailbox_docs = date_docs.into_iter().map(|(_, addr)| addr).collect();
}
SortBy::SIZE => {
let size_docs: Vec<(u64, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_SIZE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
mailbox_docs = size_docs.into_iter().map(|(_, addr)| addr).collect();
}
}
let mut result = Vec::new();
for (_, doc_address) in mailbox_docs {
for doc_address in mailbox_docs {
let doc: TantivyDocument = searcher
.doc_async(doc_address)
.await
@@ -849,7 +932,6 @@ impl EnvelopeIndexManager {
}
}
pub async fn top_10_largest_emails(
&self,
accounts: &Option<HashSet<u64>>,
@@ -1260,7 +1342,17 @@ impl EmlIndexManager {
}
fn open_or_create_index(index_dir: &PathBuf) -> Index {
if !index_dir.exists() {
let need_create = !index_dir.exists()
|| index_dir
.read_dir()
.map(|mut d| d.next().is_none())
.unwrap_or(true);
if need_create {
info!(
"Email data storage not found or empty, creating new mail storage at {}",
index_dir.display()
);
std::fs::create_dir_all(&index_dir).unwrap_or_else(|e| {
panic!("Failed to create index directory {:?}: {}", index_dir, e)
});
@@ -1276,6 +1368,10 @@ impl EmlIndexManager {
.create_in_dir(&index_dir)
.unwrap_or_else(|e| panic!("Failed to create index in {:?}: {}", index_dir, e))
} else {
info!(
"Opening existing email data storage at {}",
index_dir.display()
);
open(&index_dir)
}
}

View File

@@ -16,8 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::logger::file::setup_file_logger;
use crate::modules::logger::file::setup_file_logger;
use crate::modules::settings::cli::SETTINGS;
use chrono::Local;
use std::process;

View File

@@ -0,0 +1,38 @@
use crate::modules::{
cache::imap::mailbox::MailBox,
error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
};
pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResult<()> {
let mailbox = MailBox::get(mailbox_id).await?;
let name = mailbox.name;
let delimiter = mailbox.delimiter.unwrap_or("/".to_owned());
let all_mailboxes = MailBox::list_all(account_id).await?;
let prefix = format!("{}{}", name, delimiter);
let ids_to_delete: Vec<u64> = all_mailboxes
.into_iter()
.filter(|m| m.id == mailbox_id || m.name.starts_with(&prefix))
.map(|m| m.id)
.collect();
if ids_to_delete.is_empty() {
return Ok(());
}
for id in &ids_to_delete {
MailBox::delete(*id).await?;
}
ENVELOPE_INDEX_MANAGER
.delete_mailbox_envelopes(account_id, ids_to_delete.clone())
.await?;
EML_INDEX_MANAGER
.delete_mailbox_envelopes(account_id, ids_to_delete)
.await?;
Ok(())
}

View File

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

View File

@@ -0,0 +1,10 @@
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct Contact {
pub email: String,
pub name: Option<String>,
}

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod append;
pub mod contacts;
pub mod content;
pub mod delete;
pub mod list;

View File

@@ -18,7 +18,7 @@
use std::collections::HashSet;
use poem_openapi::Object;
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use crate::{
@@ -39,8 +39,8 @@ pub struct SearchFilter {
pub bcc: Option<String>,
pub since: Option<i64>,
pub before: Option<i64>,
pub account_id: Option<u64>,
pub mailbox_id: Option<u64>,
pub account_ids: Option<Vec<u64>>,
pub mailbox_ids: Option<Vec<u64>>,
pub min_size: Option<u64>,
pub max_size: Option<u64>,
pub message_id: Option<String>,
@@ -49,11 +49,20 @@ pub struct SearchFilter {
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
pub enum SortBy {
#[default]
DATE,
SIZE,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct SearchRequest {
filter: SearchFilter,
page: u64,
page_size: u64,
sort_by: Option<SortBy>,
desc: Option<bool>,
}
impl SearchRequest {
pub fn validate(&self) -> BichonResult<()> {
@@ -84,7 +93,8 @@ pub async fn search_messages_impl(
request.filter,
request.page,
request.page_size,
true,
request.desc.unwrap_or(true),
request.sort_by.unwrap_or(SortBy::DATE),
)
.await
}

View File

@@ -19,6 +19,7 @@
pub mod account;
pub mod autoconfig;
pub mod cache;
pub mod cli;
pub mod common;
pub mod context;
pub mod dashboard;

View File

@@ -221,10 +221,13 @@ impl AccountApi {
)]
async fn minimal_accounts_list(
&self,
only_nosync: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let is_admin = context.user.is_admin().await;
let minimal_list = AccountModel::minimal_list().await?;
let only_nosync = only_nosync.0.unwrap_or_default();
let minimal_list = AccountModel::minimal_list(only_nosync).await?;
if is_admin {
return Ok(Json(minimal_list));
}

View File

@@ -18,6 +18,7 @@
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::common::auth::ClientContext;
use crate::modules::mailbox::delete::delete_mailbox_impl;
use crate::modules::mailbox::list::get_account_mailboxes;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
@@ -58,4 +59,32 @@ impl MailBoxApi {
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}
/// Deletes a mailbox for the specified account.
///
/// Requires `DATA_DELETE` permission on the target account.
///
/// # Parameters
/// - `account_id`: Account identifier.
/// - `mailbox_id`: Mailbox identifier.
///
#[oai(
path = "/delete-mailbox/:account_id/:mailbox_id",
method = "delete",
operation_id = "delete_mailbox"
)]
async fn delete_mailbox(
&self,
/// The unique identifier of the account.
account_id: Path<u64>,
mailbox_id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context
.require_permission(Some(account_id), Permission::DATA_DELETE)
.await?;
Ok(delete_mailbox_impl(account_id, mailbox_id).await?)
}
}

View File

@@ -323,4 +323,25 @@ impl MessageApi {
.await?;
Ok(())
}
#[oai(
path = "/all-contacts",
method = "get",
operation_id = "get_all_contacts"
)]
async fn get_all_contacts(&self, context: ClientContext) -> ApiResult<Json<HashSet<String>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_INDEX_MANAGER
.get_all_contacts(authorized_ids)
.await?,
))
}
}

View File

@@ -27,7 +27,7 @@ use crate::modules::users::payload::{
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
};
use crate::modules::users::permissions::Permission;
use crate::modules::users::role::UserRole;
use crate::modules::users::role::{RoleType, UserRole};
use crate::modules::users::view::UserView;
use crate::modules::users::UserModel;
use poem::web::Path;
@@ -101,10 +101,7 @@ impl UsersApi {
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
let users = UserModel::list_all().await?;
let users = users
.into_iter()
.map(|u| u.to_view(&role_lookup))
.collect();
let users = users.into_iter().map(|u| u.to_view(&role_lookup)).collect();
Ok(Json(users))
}
@@ -214,4 +211,21 @@ impl UsersApi {
Ok(Json(minimal_list))
}
#[oai(
path = "/list-account-roles",
method = "get",
operation_id = "list_account_roles"
)]
async fn list_account_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_VIEW)
.await?;
let all = UserRole::list_all().await?;
Ok(Json(
all.into_iter()
.filter(|r| matches!(r.role_type, RoleType::Account))
.collect(),
))
}
}

View File

@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::settings::io::check_dir_read_write;
use clap::{builder::ValueParser, Parser, ValueEnum};
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
@@ -46,16 +47,16 @@ pub struct Settings {
)]
pub bichon_http_port: i32,
/// The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1).
/// The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).
#[clap(
long,
env,
default_value = "0.0.0.0",
help = "The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1). Required in cluster mode.",
help = "The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).",
value_parser = ValueParser::new(|s: &str| {
// Ensure the input is a valid IPv4 address
if s.parse::<std::net::Ipv4Addr>().is_err() {
return Err("The bind IP address must be a valid IPv4 address.".to_string());
// Ensure the input is a valid IPv4 or IPv6 address
if s.parse::<std::net::Ipv4Addr>().is_err() && s.parse::<std::net::Ipv6Addr>().is_err() {
return Err("The bind IP address must be a valid IPv4 or IPv6 address.".to_string());
}
// If the address is valid, return it
@@ -64,7 +65,7 @@ pub struct Settings {
)]
pub bichon_bind_ip: Option<String>,
/// RustMail public URL (default: "http://localhost:15630")
/// bichon public URL (default: "http://localhost:15630")
#[clap(
long,
default_value = "http://localhost:15630",
@@ -160,20 +161,48 @@ pub struct Settings {
help = "Set the file path for bichon database",
value_parser = ValueParser::new(|s: &str| {
let path = PathBuf::from(s);
if !path.is_absolute() {
return Err("Path must be an absolute directory path".to_string());
}
if !path.exists() {
return Err(format!("Path {:?} does not exist", path));
}
if !path.is_dir() {
return Err(format!("Path {:?} is not a directory", path));
return Err("'bichon_root_dir' must be an absolute directory path".to_string());
}
check_dir_read_write(&path)?;
Ok(s.to_string())
})
)]
pub bichon_root_dir: String,
#[clap(
long,
env,
help = "Set the file path for email index directory",
value_parser = ValueParser::new(|s: &str| {
let path = PathBuf::from(s);
if !path.is_absolute() {
return Err("'bichon_index_dir' must be an absolute directory path".to_string());
}
check_dir_read_write(&path)?;
Ok(s.to_string())
})
)]
pub bichon_index_dir: Option<String>,
#[clap(
long,
env,
help = "Set the file path for email data directory",
value_parser = ValueParser::new(|s: &str| {
let path = PathBuf::from(s);
if !path.is_absolute() {
return Err("'bichon_data_dir' must be an absolute directory path".to_string());
}
check_dir_read_write(&path)?;
Ok(s.to_string())
})
)]
pub bichon_data_dir: Option<String>,
#[clap(
long,
env,

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::context::Initialize;
use crate::modules::settings::cli::SETTINGS;
use crate::{
@@ -35,7 +34,6 @@ const LOG_DIR: &str = "logs";
const TLS_CERT: &str = "cert.pem";
const TLS_KEY: &str = "key.pem";
pub static DATA_DIR_MANAGER: LazyLock<DataDirManager> =
LazyLock::new(|| DataDirManager::new(PathBuf::from(&SETTINGS.bichon_root_dir)));
@@ -49,7 +47,7 @@ pub struct DataDirManager {
pub tls_key: PathBuf,
pub envelope_dir: PathBuf,
pub eml_dir: PathBuf,
pub log_dir: PathBuf
pub log_dir: PathBuf,
}
impl Initialize for DataDirManager {
@@ -66,6 +64,18 @@ impl Initialize for DataDirManager {
impl DataDirManager {
pub fn new(root_dir: PathBuf) -> Self {
let envelope_dir = if let Some(ref index_dir) = SETTINGS.bichon_index_dir {
PathBuf::from(index_dir)
} else {
root_dir.join(ENVELOPE_DIR)
};
let eml_dir = if let Some(ref data_dir) = SETTINGS.bichon_data_dir {
PathBuf::from(data_dir)
} else {
root_dir.join(EML_DIR)
};
Self {
root_dir: root_dir.clone(),
meta_db: root_dir.join(META_FILE),
@@ -73,9 +83,9 @@ impl DataDirManager {
tls_key: root_dir.join(TLS_KEY),
tls_cert: root_dir.join(TLS_CERT),
log_dir: root_dir.join(LOG_DIR),
envelope_dir: root_dir.join(ENVELOPE_DIR),
envelope_dir,
temp_dir: root_dir.join(TMP_DIR),
eml_dir: root_dir.join(EML_DIR),
eml_dir,
}
}
}

View File

@@ -0,0 +1,41 @@
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::Path;
pub fn check_dir_read_write(path: &Path) -> Result<(), String> {
if !path.exists() {
fs::create_dir_all(path)
.map_err(|e| format!("Cannot create directory {:?}: {}", path, e))?;
}
if !path.is_dir() {
return Err(format!("{:?} is not a directory", path));
}
let test_file = path.join(".bichon_perm_test");
{
let mut f = OpenOptions::new()
.create(true)
.write(true)
.open(&test_file)
.map_err(|e| format!("Directory {:?} is not writable: {}", path, e))?;
f.write_all(b"test")
.map_err(|e| format!("Directory {:?} is not writable: {}", path, e))?;
}
{
let mut buf = Vec::new();
let mut f = OpenOptions::new()
.read(true)
.open(&test_file)
.map_err(|e| format!("Directory {:?} is not readable: {}", path, e))?;
f.read_to_end(&mut buf)
.map_err(|e| format!("Directory {:?} is not readable: {}", path, e))?;
}
let _ = fs::remove_file(&test_file);
Ok(())
}

View File

@@ -23,6 +23,7 @@ use crate::modules::settings::cli::Settings;
pub mod cli;
pub mod dir;
pub mod io;
pub mod proxy;
pub mod system;

View File

@@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize};
use crate::modules::{
database::{list_all_impl, manager::DB_MANAGER},
error::BichonResult,
users::BichonUser,
users::UserModel,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
@@ -34,7 +34,7 @@ pub struct MinimalUser {
impl MinimalUser {
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
let all_users = list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?;
let all_users = list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?;
let minimal_list = all_users
.into_iter()
.map(|user| MinimalUser {

View File

@@ -469,7 +469,7 @@ impl BichonUserV2 {
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<BichonUser>(id)
.primary::<UserModel>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -500,13 +500,24 @@ impl BichonUserV2 {
let password_changed = request.password.is_some();
//
let is_default_admin = id == DEFAULT_ADMIN_USER_ID;
let is_valid_admin_roles = matches!(
request.global_roles.as_deref(),
Some([role]) if *role == DEFAULT_ADMIN_ROLE_ID
);
if is_default_admin && !is_valid_admin_roles {
return Err(raise_error!(format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id), ErrorCode::Forbidden));
if is_default_admin {
if let Some(roles) = request.global_roles.as_deref() {
let is_valid = matches!(
roles,
[role] if *role == DEFAULT_ADMIN_ROLE_ID
);
if !is_valid {
return Err(raise_error!(
format!(
"The role assignments for default admin (id={}) are immutable to ensure system accessibility.",
id
),
ErrorCode::Forbidden
));
}
}
}
if let Some(username) = &request.username {

View File

@@ -222,9 +222,9 @@ impl UserCreateRequest {
let username_len = self.username.len();
// 1. Username constraints
if username_len < 5 {
if username_len < 3 {
return Err(raise_error!(
"Username must be at least 5 characters long.".into(),
"Username must be at least 3 characters long.".into(),
ErrorCode::InvalidParameter
));
}
@@ -351,9 +351,9 @@ impl UserUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(username) = &self.username {
let len = username.len();
if len < 5 || len > 32 {
if len < 3 || len > 32 {
return Err(raise_error!(
"Username must be 5-32 characters.".into(),
"Username must be 3-32 characters.".into(),
ErrorCode::InvalidParameter
));
}

View File

@@ -53,7 +53,8 @@ impl Permission {
/// Create, modify, and delete all users and their roles (Admin only).
pub const USER_MANAGE: &str = "user:manage";
/// View the minimal user list and basic profiles (Managers and Admins).
/// View the minimal user list, basic user profiles,
/// including visibility into account-level roles (Managers and Admins).
pub const USER_VIEW: &str = "user:view";
/// View and revoke all access tokens in the system.

View File

@@ -39,6 +39,8 @@
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-visually-hidden": "^1.1.0",
"@react-spring/web": "^10.0.3",

6
web/pnpm-lock.yaml generated
View File

@@ -86,6 +86,12 @@ importers:
'@radix-ui/react-toast':
specifier: ^1.2.2
version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-toggle':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-toggle-group':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-tooltip':
specifier: ^1.1.4
version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)

View File

@@ -21,6 +21,7 @@ import axiosInstance from "@/api/axiosInstance";
export interface MailboxData {
account_id: number;
attributes: { attr: string; extension: string | null }[];
delimiter: string | null;
exists: number;
@@ -34,4 +35,10 @@ export interface MailboxData {
export const list_mailboxes = async (accountId: number, remote: boolean) => {
const response = await axiosInstance.get<MailboxData[]>(`/api/v1/list-mailboxes/${accountId}?remote=${remote}`);
return response.data;
};
export const delete_mailbox = async (accountId: number, mailboxId: string) => {
const response = await axiosInstance.delete(`/api/v1/delete-mailbox/${accountId}/${mailboxId}`);
return response.data;
};

View File

@@ -30,7 +30,7 @@ export interface TagCount {
count: number;
}
export const get_top_tags = async () => {
export const get_tags = async () => {
const response = await axiosInstance.get<TagCount[]>("/api/v1/all-tags");
return response.data;
}
@@ -41,3 +41,9 @@ export const update_tags = async (data: Record<string, any>) => {
};
export const get_contacts = async () => {
const response = await axiosInstance.get<string[]>("/api/v1/all-contacts");
return response.data;
}

View File

@@ -180,6 +180,11 @@ export const list_minimal_users = async () => {
return response.data;
};
export const list_account_roles = async () => {
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-account-roles");
return response.data;
};
export const remove_user = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/users/${id}`);
return response.data;

View File

@@ -37,7 +37,7 @@ export function DatePicker({
{selected ? (
format(selected, 'PPP', { locale: dateLocale })
) : (
<span>{placeholder}</span>
<span className='text-xs'>{placeholder}</span>
)}
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
</Button>

View File

@@ -27,6 +27,7 @@ import {
import { NavGroup } from '@/components/layout/nav-group'
import Logo from '@/assets/logo.svg'
import { useSidebarData } from './data/sidebar-data'
import { Link } from '@tanstack/react-router';
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { open } = useSidebar();
@@ -36,22 +37,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarHeader>
<SidebarMenuButton
size='lg'
asChild
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
<div className='flex aspect-square size-16 items-center justify-center rounded-lg text-sidebar-primary-foreground'>
<img
className={open ? "relative ml-[12px] mr-[12px]" : "mr-[30px]"}
src={Logo}
width={open ? 60 : 40}
height={open ? 60 : 40}
alt='Logo'
/>
</div>
<div className='grid flex-1 text-left text-lg leading-tight'>
<span className='truncate font-semibold'>
Bichon
</span>
</div>
<Link to="/">
<div className='flex aspect-square size-16 items-center justify-center rounded-lg text-sidebar-primary-foreground'>
<img
className={open ? "relative ml-[12px] mr-[12px]" : "mr-[30px]"}
src={Logo}
width={open ? 60 : 40}
height={open ? 60 : 40}
alt='Logo'
/>
</div>
<div className='grid flex-1 text-left text-lg leading-tight'>
<span className='truncate font-semibold'>
Bichon
</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarHeader>
<SidebarContent>

View File

@@ -24,6 +24,7 @@ import {
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Button } from '@/components/ui/button'
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
@@ -33,14 +34,15 @@ import {
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
import { showNumbers } from '@/lib/utils'
import { useEffect, useState } from 'react'
interface PaginationProps {
totalItems: number
pageIndex: number,
pageSize: number,
hasNextPage: () => boolean,
setPageIndex: (pageIndex: number) => void,
setPageSize: (pageSize: number) => void,
pageIndex: number
pageSize: number
hasNextPage: () => boolean
setPageIndex: (pageIndex: number) => void
setPageSize: (pageSize: number) => void
}
export function EnvelopeListPagination({
@@ -52,8 +54,13 @@ export function EnvelopeListPagination({
setPageSize,
}: PaginationProps) {
const { t } = useTranslation()
const [pageInput, setPageInput] = useState(pageIndex + 1)
const pageCount = Math.ceil(totalItems / pageSize)
useEffect(() => {
setPageInput(pageIndex + 1)
}, [pageIndex])
const handlePageSizeChange = (value: string) => {
const newPageSize = Number(value)
setPageSize(newPageSize)
@@ -69,7 +76,7 @@ export function EnvelopeListPagination({
setPageIndex(newPageIndex)
}
const currentPage = pageIndex + 1;
const currentPage = pageIndex + 1
const pageNumbers = showNumbers(currentPage, pageCount)
return (
@@ -88,7 +95,7 @@ export function EnvelopeListPagination({
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50, 100].map((size) => (
{[10, 20, 30, 40, 50, 100, 200].map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
@@ -96,8 +103,20 @@ export function EnvelopeListPagination({
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-center text-sm font-medium'>
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
<div className='hidden items-center justify-center text-sm font-medium sm:flex'>
{t("table.page")}
<Input
type="number"
value={pageInput}
onBlur={() => {
if (Number.isNaN(pageInput)) return
if (pageInput > 0) setPageIndex(pageInput - 1)
else setPageIndex(0)
}}
onChange={(e) => setPageInput(Number(e.target.value))}
className='mx-2 h-8 w-20'
/>
{t("table.of")} {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button
@@ -154,4 +173,4 @@ export function EnvelopeListPagination({
</div>
</div>
)
}
}

View File

@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
interface ScrollAreaProps
extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
orientation?: 'horizontal' | 'vertical'
orientation?: 'horizontal' | 'vertical' | 'both'
}
const ScrollArea = React.forwardRef<
@@ -24,7 +24,12 @@ const ScrollArea = React.forwardRef<
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar orientation={orientation} />
{orientation === "both" ? (
<>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</>
) : <ScrollBar orientation={orientation} />}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
@@ -40,9 +45,9 @@ const ScrollBar = React.forwardRef<
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}

View File

@@ -5,13 +5,13 @@ const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className='relative w-full overflow-auto'>
// <div className='relative w-full overflow-auto'>
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
// </div>
))
Table.displayName = 'Table'
@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
<thead ref={ref} className={cn('[&_th]:sticky [&_th]:top-0 [&_th]:bg-background [&_th]:z-20', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -224,6 +224,7 @@ interface VirtualizedSelectProps {
defaultValue?: string | string[];
noItemsComponent?: React.ReactNode;
multiple?: boolean;
size?: 'default' | 'sm' | 'lg' | 'icon';
}
export function VirtualizedSelect({
@@ -232,6 +233,7 @@ export function VirtualizedSelect({
className,
defaultValue,
value,
size = 'default',
isLoading,
disabled = false,
placeholder = 'Search items...',
@@ -273,7 +275,7 @@ export function VirtualizedSelect({
.filter(Boolean);
if (selectedLabels.length === 0) return placeholder;
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
return selectedLabels.join(", ");
};
return (
@@ -281,13 +283,14 @@ export function VirtualizedSelect({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
size={size}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('justify-between', className)}
disabled={isLoading || disabled}
>
{getDisplayText()}
<span className='truncate'>{getDisplayText()}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>

View File

@@ -20,7 +20,7 @@ import React from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -52,9 +52,8 @@ import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { useToast } from '@/hooks/use-toast'
import { useRoles } from '@/hooks/use-roles'
import { useMinimalUsers } from '@/hooks/use-minimal-users'
import { access_assign, AccountModel } from '@/api/account/api'
import { list_account_roles, list_minimal_users, MinimalUser, UserRole } from '@/api/users/api'
interface Props {
currentRow: AccountModel
@@ -71,12 +70,23 @@ export function AccountAccessAssignmentDialog({
const { toast } = useToast()
const queryClient = useQueryClient()
const { accountRoles, isLoading: isLoadingRoles } = useRoles()
const { users, isLoading: isLoadingUsers } = useMinimalUsers()
const { data: roles, isLoading: isLoadingRoles } = useQuery<UserRole[]>({
queryKey: ['account-role-list'],
queryFn: list_account_roles,
staleTime: 5 * 60 * 1000,
enabled: open,
});
const { data: users, isLoading: isLoadingUsers } = useQuery<MinimalUser[]>({
queryKey: ['minimal-user-list'],
queryFn: list_minimal_users,
staleTime: 5 * 60 * 1000,
enabled: open,
});
const [keyword, setKeyword] = React.useState('')
// 1. 定义校验 Schema (集成国际化错误提示)
const assignmentSchema = z.object({
account_ids: z.array(z.number()),
user_ids: z.array(z.number()).min(1, {
@@ -101,7 +111,7 @@ export function AccountAccessAssignmentDialog({
const filteredUsers = React.useMemo(() => {
if (!keyword.trim()) return users
const lowerKeyword = keyword.toLowerCase()
return users.filter(
return users!.filter(
(user) =>
user.username.toLowerCase().includes(lowerKeyword) ||
user.email.toLowerCase().includes(lowerKeyword)
@@ -170,7 +180,7 @@ export function AccountAccessAssignmentDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
{accountRoles.map((role) => (
{roles && roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name}
</SelectItem>
@@ -206,12 +216,12 @@ export function AccountAccessAssignmentDialog({
</div>
) : (
<div className="p-3 space-y-1">
{filteredUsers.length === 0 ? (
{filteredUsers && (filteredUsers.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
{t('accounts.access_control.user_empty')}
</div>
) : (
filteredUsers.map((user) => (
filteredUsers!.map((user) => (
<FormField
key={user.id}
control={form.control}
@@ -242,7 +252,7 @@ export function AccountAccessAssignmentDialog({
)}
/>
))
)}
))}
</div>
)}
</ScrollArea>

View File

@@ -128,6 +128,7 @@ const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
.int()
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
.nullable()
.optional(),
sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
sync_batch_size: z
@@ -221,7 +222,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const accountSchema = getAccountSchema(isEdit, t);
const form = useForm<Account>({
mode: "all",
mode: "onChange",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema),
});
@@ -291,9 +292,11 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
};
if (isEdit) {
const isAllMode = !data.date_since && !data.date_before;
const clear_folder_limit = !data.folder_limit;
updateMutation.mutate({
...commonData,
...(isAllMode ? { clear_date_range: true } : {})
...(isAllMode ? { clear_date_range: true } : {}),
...(clear_folder_limit ? { clear_folder_limit: true } : {})
});
} else {
createMutation.mutate({ ...commonData, account_type: "IMAP" });
@@ -355,7 +358,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
onOpenChange(state);
}}
>
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[90vh]">
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[50rem]">
<div className="p-6 pb-2 flex-shrink-0">
<DialogHeader className="text-left">
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>

View File

@@ -116,11 +116,11 @@ export function useColumns(): ColumnDef<AccountModel>[] {
cell: ({ row }) => {
const { created_user_name, created_user_email } = row.original;
return (
<div className="flex flex-col py-1 text-center">
<span className="text-sm font-medium text-foreground">
<div className="flex flex-col items-center leading-[1.1]">
<span className="text-[13px] font-medium text-foreground leading-none">
{created_user_name}
</span>
<span className="text-[11px] text-muted-foreground font-mono">
<span className="text-[11px] text-muted-foreground font-mono leading-none">
{created_user_email}
</span>
</div>

View File

@@ -82,7 +82,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const { toast } = useToast();
const form = useForm<NoSyncAccount>({
mode: "all",
mode: "onChange",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema(t)),
});
@@ -164,7 +164,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
<ScrollArea className='h-[13rem] w-full pr-4 -mr-4 py-1'>
<Form {...form}>
<form
id='nosync-account-form'

View File

@@ -85,7 +85,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="text-blue-500 font-medium truncate">{currentRow.email}</span>
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[85vh] px-4 sm:px-6 pb-6">
<ScrollArea className="max-h-[55rem] px-4 sm:px-6 pb-6">
{isLoading && (
<div className="space-y-4 py-6">
<Skeleton className="h-6 w-1/2" />

View File

@@ -178,7 +178,6 @@ export default function Step3() {
onSelect={(date) => field.onChange(date?.toLocaleDateString('en-CA'))}
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
locale={dateLocale}
initialFocus
/>
</PopoverContent>
</Popover>
@@ -244,8 +243,11 @@ export default function Step3() {
<Input
type="number"
placeholder={t('accounts.folderLimitPlaceholder')}
{...field}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)}
value={field.value ?? ''}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? null : Number(value));
}}
/>
</FormControl>
<FormMessage />

View File

@@ -164,7 +164,7 @@ export default function MailArchiveDashboard() {
return (
<>
<FixedHeader />
<Main>
<Main higher>
<div className="flex-1 space-y-6 p-6 md:p-8">
<div className="flex items-center justify-between">
<div>
@@ -304,7 +304,6 @@ export default function MailArchiveDashboard() {
</Card>
</TabsContent>
{/* Attachments */}
<TabsContent value="attachment" className="space-y-4">
<Card>
<CardHeader>
@@ -459,7 +458,6 @@ export default function MailArchiveDashboard() {
</Tabs>
</div>
{/* Footer / Copyright - New Addition */}
<div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground">
© 2025 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
</div>

View File

@@ -0,0 +1,105 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { IconAlertTriangle } from '@tabler/icons-react';
import { toast } from '@/hooks/use-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMailboxContext } from '../context';
import { useTranslation } from 'react-i18next';
import { delete_mailbox } from '@/api/mailbox/api';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useMailboxContext();
const { t } = useTranslation();
const deleteMutation = useMutation({
mutationFn: ({ accountId, mailboxId }: { accountId: number; mailboxId: string }) =>
delete_mailbox(accountId, mailboxId),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['account-mailboxes', `${selectedAccountId}`] });
onOpenChange(false);
setDeleteMailboxId(undefined);
toast({
title: t('mailbox.deleteMailboxDialog.successTitle'),
description: t('mailbox.deleteMailboxDialog.successDesc'),
});
},
onError: (error: any) => {
toast({
title: t('mailbox.deleteMailboxDialog.errorTitle'),
description: error.message || "Delete failed",
variant: 'destructive',
});
},
});
const handleDelete = () => {
if (selectedAccountId && deleteMailboxId) {
deleteMutation.mutate({
accountId: selectedAccountId,
mailboxId: deleteMailboxId
});
}
};
const isLoading = deleteMutation.isPending;
return (
<ConfirmDialog
open={open}
onOpenChange={(isOpen) => {
onOpenChange(isOpen);
if (!isOpen) setDeleteMailboxId(undefined);
}}
handleConfirm={handleDelete}
className="max-w-xl"
isLoading={isLoading}
title={
<span className="text-destructive">
<IconAlertTriangle
className="mr-1 inline-block stroke-destructive"
size={18}
/>{' '}
{t('mailbox.deleteMailboxDialog.title')}
</span>
}
desc={
<div className="space-y-4">
<p className="mb-2">
{t('mailbox.deleteMailboxDialog.desc')}
</p>
<Alert variant="destructive">
<AlertTitle>{t('mailbox.deleteMailboxDialog.warningTitle')}</AlertTitle>
<AlertDescription>{t('mailbox.deleteMailboxDialog.warningDesc')}</AlertDescription>
</Alert>
</div>
}
confirmText={t('mailbox.deleteMailboxDialog.confirm')}
destructive
/>
);
}

View File

@@ -111,7 +111,7 @@ export function MailList({
/>
<span className="text-xs text-muted-foreground">
{selected.size > 0
? `${selected.size} ${t('common.selected')}`
? `${t('search.bulkActions.selected', { count: selected.size })}`
: t('common.selectAll')}
</span>
</div>

View File

@@ -19,7 +19,7 @@
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore } from 'lucide-react';
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileAudio, FileVideo, FileSpreadsheet, FileArchive, FileCode, FileIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -83,6 +83,35 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines
);
};
const getFileConfig = (mimeType: string) => {
const type = mimeType.toLowerCase();
if (type.includes('pdf')) {
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
}
if (type.includes('image/')) {
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
}
if (type.includes('audio/')) {
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
}
if (type.includes('video/')) {
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
}
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
}
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
}
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
}
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
};
export function MailMessageView({
envelope,
showActions = true,
@@ -245,11 +274,24 @@ export function MailMessageView({
const nonInline = attachments.filter((a) => !a.inline);
return nonInline.length > 0 ? (
<div className="space-y-2">
{nonInline.map((attachment, i) => (
<div key={i} className="flex items-center">
<div className="flex items-center space-x-8">
<span className="truncate text-xs">{attachment.filename}</span>
<span className="text-xs px-2 py-1 rounded">[{attachment.file_type}]</span>
{nonInline.map((attachment, i) => {
const { icon, color } = getFileConfig(attachment.file_type);
return <div key={i} className="flex items-center">
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
<div className={`flex-shrink-0 ${color}`}>
{icon}
</div>
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
<span
className="truncate text-xs font-medium text-foreground/90"
title={attachment.filename}
>
{attachment.filename}
</span>
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
{attachment.file_type.split('/').pop()?.toUpperCase()}
</span>
</div>
</div>
<div className="flex items-center space-x-4 ml-auto">
<span className="text-gray-500 text-xs shrink-0">
@@ -268,7 +310,7 @@ export function MailMessageView({
)}
</div>
</div>
))}
})}
</div>
) : (
<span className="text-gray-500 text-xs italic">

View File

@@ -49,8 +49,12 @@ import { styled } from "@mui/material/styles"
import { animated, useSpring } from "@react-spring/web"
import { TransitionProps } from "@mui/material/transitions"
import Collapse from "@mui/material/Collapse"
import { FolderIcon } from "lucide-react"
import { FolderIcon, MoreVertical, Trash2 } from "lucide-react"
import { RestoreMessageDialog } from "./restore-message-dialog"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { useTranslation } from "react-i18next"
import { MailBoxDeleteDialog } from "./delete-mailbox-dialog"
interface MailProps {
@@ -79,15 +83,14 @@ const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessages
});
};
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
id: string;
icon?: React.ElementType;
expandable?: boolean;
onDelete: (id: string) => void;
}
function CustomLabel({
@@ -95,8 +98,11 @@ function CustomLabel({
exists,
attributes,
children,
id,
onDelete,
...other
}: CustomLabelProps) {
const { t } = useTranslation()
return (
<TreeItemLabel
{...other}
@@ -109,27 +115,39 @@ function CustomLabel({
<span className="font-medium text-sm text-inherit">
{children}
</span>
{/* <div className="flex gap-2 ml-auto mr-3 opacity-70 text-xs">
{attributes?.map((attr) => {
const text =
attr.attr === 'Extension'
? attr.extension
: attr.attr;
return (
<span key={attr.attr} className="text-inherit">
{text}
</span>
);
})}
<div className="ml-auto flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-20">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
}}
onSelect={(e) => {
e.preventDefault();
onDelete(id);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t('common.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{exists !== undefined && (
<span
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
>
{exists}
</span>
)} */}
</TreeItemLabel>
);
}
@@ -172,6 +190,8 @@ export function Mail({
const [pageSize, setPageSize] = React.useState(30);
const [deleteIds, setDeleteIds] = React.useState<Set<number>>(() => new Set());
const [selected, setSelected] = React.useState<Set<number>>(() => new Set());
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
const { theme } = useTheme()
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
@@ -225,6 +245,11 @@ export function Mail({
}
};
const handleDeleteClick = (id: string) => {
setDeleteMailboxId(id);
setOpen('delete');
};
const CustomTreeItem = React.useMemo(() => {
return React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
@@ -257,6 +282,8 @@ export function Mail({
<CustomLabel
{...getLabelProps({
exists: item.exists,
id: item.id,
onDelete: handleDeleteClick,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
@@ -270,11 +297,22 @@ export function Mail({
});
}, [theme]);
return (
<MailboxProvider value={{ open, setOpen, currentMailbox: selectedMailbox, selectedAccountId, setCurrentMailbox: setSelectedMailbox, currentEnvelope: selectedEvelope, setCurrentEnvelope: setSelectedEvelope, deleteIds, setDeleteIds, selected, setSelected }}>
<MailboxProvider value={{
open,
setOpen,
currentMailbox: selectedMailbox,
selectedAccountId,
setCurrentMailbox: setSelectedMailbox,
currentEnvelope: selectedEvelope,
setCurrentEnvelope: setSelectedEvelope,
deleteIds,
setDeleteIds,
selected,
setSelected,
deleteMailboxId,
setDeleteMailboxId
}}>
<TooltipProvider delayDuration={0}>
<ResizablePanelGroup
direction="horizontal"
@@ -302,7 +340,7 @@ export function Mail({
)}
>
<Separator className="mb-2" />
<ScrollArea className='h-[50rem] w-full pr-4 -mr-4 py-1'>
<ScrollArea className='h-[calc(100vh-8rem)] w-full pr-4 -mr-4 py-1'>
<div>
<AccountSwitcher onAccountSelect={(accountId) => {
localStorage.setItem('mailbox:selectedAccountId', `${accountId}`);
@@ -351,7 +389,7 @@ export function Mail({
</div>
<Separator />
<div className="mt-2">
<ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
<ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1'>
<MailList
isLoading={isMessagesLoading}
items={(envelopes?.items ?? []).sort((a, b) => {
@@ -409,6 +447,11 @@ export function Mail({
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
<MailBoxDeleteDialog
key='mailbox-delete'
open={open === 'delete'}
onOpenChange={() => setOpen('delete')}
/>
</MailboxProvider >
)

View File

@@ -21,7 +21,7 @@ import React from 'react'
import { MailboxData } from '@/api/mailbox/api'
import { EmailEnvelope } from '@/api'
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore'
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore' | 'delete'
interface MailboxContextType {
open: MailboxDialogType | null
@@ -30,6 +30,8 @@ interface MailboxContextType {
currentMailbox: MailboxData | undefined
currentEnvelope: EmailEnvelope | undefined
setCurrentMailbox: React.Dispatch<React.SetStateAction<MailboxData | undefined>>
deleteMailboxId: string | undefined,
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
setCurrentEnvelope: React.Dispatch<React.SetStateAction<EmailEnvelope | undefined>>
deleteIds: Set<number>
setDeleteIds: React.Dispatch<React.SetStateAction<Set<number>>>

View File

@@ -32,7 +32,6 @@ export default function Mailboxes() {
return (
<>
{/* ===== Top Heading ===== */}
<FixedHeader />
<Main>
<Mail

View File

@@ -148,7 +148,7 @@ export function Oauth2Table({ columns, data }: DataTableProps) {
</TableBody>
</Table>
</div>
<DataTablePagination table={table} showSelected={true} showPageSizeSelector={true} />
<DataTablePagination table={table} showPageSizeSelector={true} />
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { AccountPopover } from './account-popover'
import { MailboxPopover } from './mailbox-popover'
export function AccountMailboxFilter() {
return (
<>
<AccountPopover />
<MailboxPopover />
</>
)
}

View File

@@ -0,0 +1,185 @@
import * as React from 'react'
import { AtSign, ChevronDown, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { cn } from '@/lib/utils'
import { useSearchContext } from './context'
export function AccountPopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const [search, setSearch] = React.useState('')
const { minimalList = [] } = useMinimalAccountList()
const selectedIds: number[] = filter.account_ids ?? []
const toggleAccount = (id: number) => {
setFilter(prev => {
const next = { ...prev }
const set = new Set<number>(next.account_ids ?? [])
if (set.has(id)) {
set.delete(id)
} else {
set.add(id)
}
if (set.size === 0) {
delete next.account_ids
delete next.mailbox_ids
} else {
next.account_ids = Array.from(set).sort()
delete next.mailbox_ids
}
return next
})
}
const clearAccounts = () => {
setFilter(prev => {
const next = { ...prev }
delete next.account_ids
delete next.mailbox_ids
return next
})
}
const filtered = React.useMemo(() => {
const q = search.toLowerCase()
return minimalList
.filter(a =>
!q ||
a.email.toLowerCase().includes(q) ||
String(a.id).includes(q)
)
.sort((a, b) => {
const aSel = selectedIds.includes(a.id)
const bSel = selectedIds.includes(b.id)
if (aSel && !bSel) return -1
if (!aSel && bSel) return 1
return a.id - b.id
})
}, [minimalList, search, selectedIds])
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn(
'h-8 gap-1.5 px-3 rounded-none',
selectedIds.length > 0 &&
'bg-primary/10 border-primary text-primary'
)}
>
<AtSign className="h-4 w-4" />
{t('search_accounts.label')}
{selectedIds.length > 0 && (
<Badge
variant="secondary"
className="ml-1 h-5 px-1.5 text-xs"
>
{selectedIds.length}
</Badge>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-96 p-1">
<div className="p-1 pb-2">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('search_accounts.search_placeholder')}
className="h-8 text-sm"
/>
</div>
{!search && selectedIds.length > 0 && (
<div className="p-1">
<Button
variant="ghost"
size="sm"
onClick={clearAccounts}
className="flex h-8 w-full items-center justify-start gap-2 px-2 text-xs font-medium text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
>
<div className="flex h-4 w-4 items-center justify-center">
<X className="h-3.5 w-3.5" />
</div>
<span className="flex-1 text-left">
{t('search_accounts.clear_accounts')}
</span>
<span className="text-[10px] opacity-60 font-mono">
({selectedIds.length})
</span>
</Button>
<div className="my-1 h-px bg-border/60" />
</div>
)}
<ScrollArea className="h-96 p-1">
{filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('search_accounts.no_accounts_found')}
</p>
) : (
filtered.map(account => {
const checked = selectedIds.includes(account.id)
const id = `account-${account.id}`
return (
<div
key={account.id}
onClick={() => toggleAccount(account.id)}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
'hover:bg-accent transition-colors'
)}
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={() =>
toggleAccount(account.id)
}
onClick={e => e.stopPropagation()}
/>
<Label
htmlFor={id}
className="flex-1 truncate text-xs cursor-pointer"
>
<div className="flex items-center gap-2">
<span className="truncate">
{account.email}
</span>
<span className="text-[10px] text-muted-foreground">
#{account.id}
</span>
</div>
</Label>
</div>
)
})
)}
</ScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,53 @@
import { Paperclip, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useSearchContext } from './context'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export function AttachmentFilter() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const hasAttachment = filter?.has_attachment === true
const toggleAttachment = () => {
setFilter((prev) => {
const next = { ...prev }
if (next.has_attachment) {
delete next.has_attachment
} else {
next.has_attachment = true
}
return next
})
}
return (
<Button
size="sm"
variant="outline"
onClick={toggleAttachment}
className={cn(
"h-8 px-3 gap-2 transition-all rounded-none flex-shrink-0",
hasAttachment
? "bg-primary/10 border-primary text-primary hover:bg-primary/20 hover:text-primary z-10"
: "text-muted-foreground border-r-0"
)}
>
<Paperclip
className={cn(
"h-3.5 w-3.5",
hasAttachment ? "opacity-100" : "opacity-60"
)}
/>
<span className="text-xs font-medium">
{t('mail.attachments')}
</span>
{hasAttachment && (
<Check className="h-3 w-3 ml-0.5 stroke-[3px] animate-in zoom-in duration-200" />
)}
</Button>
)
}

View File

@@ -19,7 +19,7 @@
import { useRef } from 'react'
import { X, Trash2 } from 'lucide-react'
import { X, Trash2, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -37,7 +37,7 @@ type MailBulkActionsProps = {
}
export function MailBulkActions({ children }: MailBulkActionsProps) {
const { selected, setSelected, setOpen, setToDelete } = useSearchContext()
const { selected, setSelected, setOpen, setToDelete, setCurrentEnvelope } = useSearchContext()
const toolbarRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
@@ -60,6 +60,12 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
setOpen('delete')
}
const handleRestore = () => {
setCurrentEnvelope(undefined);
setOpen('restore')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const buttons = toolbarRef.current?.querySelectorAll('button')
if (!buttons || buttons.length === 0) return
@@ -161,7 +167,26 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
</div>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="sm"
onClick={handleRestore}
className="gap-1"
>
<Upload className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{t('restore_message.restore_to_imap', 'Restore Mail')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{t('search.bulkActions.restoreDesc')}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
{/* Delete */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -0,0 +1,217 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useSearchContext } from "./context"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { Check, ChevronDown, Mail, X } from "lucide-react"
import React from "react"
import { useContacts } from "@/hooks/use-contacts"
import { useTranslation } from 'react-i18next'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
export function MailFilterPopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const fields = ['from', 'to', 'cc', 'bcc'] as const
const activeCount = fields.filter(k => !!filter[k]).length
const updateFilter = (field: string, email: string | undefined) => {
setFilter(prev => ({
...prev,
[field]: email
}))
}
const resetAll = () => {
setFilter(prev => {
const next = { ...prev }
fields.forEach(k => delete next[k])
return next
})
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn(
'h-8 rounded-none px-3 gap-1.5 transition-colors',
activeCount > 0 && 'bg-primary/10 text-primary hover:bg-primary/20'
)}
>
<Mail className="h-3.5 w-3.5 opacity-60" />
<span>
{activeCount > 0
? t('search_contacts.label_with_count', { count: activeCount })
: t('search_contacts.label')}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-fit min-w-[280px] max-w-[90vw] sm:max-w-[min(90vw,500px)] p-0 flex flex-col divide-y divide-border shadow-xl"
>
<div className="flex flex-col bg-muted/20 divide-y divide-border">
{fields.map((field) => (
<ContactSelectorField
key={field}
label={field}
value={filter[field] as string | undefined}
onSelect={(email) => updateFilter(field, email)}
onReset={() => updateFilter(field, undefined)}
/>
))}
</div>
{activeCount > 0 && (
<div className="px-1 pb-2">
<Button
variant="ghost"
size="sm"
onClick={resetAll}
className="flex h-8 w-full items-center justify-start gap-2 px-2 text-xs font-medium text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
>
<div className="flex h-4 w-4 items-center justify-center">
<X className="h-3.5 w-3.5" />
</div>
<span className="flex-1 text-left">
{t('search_contacts.reset_all')}
</span>
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}
function ContactSelectorField({
label,
value,
onSelect,
onReset
}: {
label: string
value?: string
onSelect: (email: string | undefined) => void
onReset: () => void
}) {
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = React.useState("")
const { contacts, isLoading } = useContacts(searchTerm)
const handleToggle = (email: string) => {
if (value === email) {
onReset()
} else {
onSelect(email)
}
}
return (
<Popover>
<PopoverTrigger asChild>
<button
className={cn(
"group flex items-center justify-between w-full px-4 py-3 hover:bg-background transition-all text-left relative",
"min-h-[52px]",
value && "bg-background/60 hover:bg-background/80"
)}
>
<div className="flex flex-col items-start pr-6">
<span className="text-[10px] font-bold uppercase opacity-50 tracking-tight leading-none">
{label}
</span>
<span
className={cn(
"mt-0.5 truncate max-w-[320px]",
value
? "text-xs font-semibold text-primary"
: "text-xs text-muted-foreground/90"
)}
>
{value || t('search_contacts.any')}
</span>
</div>
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
{value && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onReset()
}}
className="p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{value && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
className="p-0 w-auto min-w-[300px] max-w-[420px] shadow-2xl border-border/50"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t('search_contacts.search_placeholder', { field: label })}
className="h-9"
value={searchTerm}
onValueChange={setSearchTerm}
/>
<CommandList className="max-h-[360px]">
{isLoading && (
<div className="p-4 text-xs text-center opacity-50">{t('search_contacts.loading')}</div>
)}
<CommandEmpty>{t('search_contacts.no_contact_found')}</CommandEmpty>
<CommandGroup>
{contacts.slice(0, 100).map((email) => (
<CommandItem
key={email}
onSelect={() => handleToggle(email)}
className="flex items-center justify-between py-2.5 px-3 cursor-pointer whitespace-nowrap gap-4 text-xs"
>
<div className="flex flex-col min-w-0">
<span className="font-medium">
{email.split('@')[0]}
</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[360px]">
{email}
</span>
</div>
{value === email && (
<Check className="h-4 w-4 text-primary shrink-0" />
)}
</CommandItem>
))}
{contacts.length > 100 && (
<div className="px-3 py-2 text-[10px] text-center text-muted-foreground border-t border-border/50">
{t('search_contacts.showing_limit', { total: contacts.length })}
</div>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -19,8 +19,9 @@
import React from 'react'
import { EmailEnvelope } from '@/api'
import { SortingState } from '@tanstack/react-table'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore'
interface SearchContextType {
open: SearchDialogType | null
@@ -32,6 +33,11 @@ interface SearchContextType {
selected: Map<number, Set<number>>
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
selectedTags: string[]
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
filter: Record<string, any>
setFilter: React.Dispatch<React.SetStateAction<Record<string, any>>>
handleTagToggle: (tag: string) => void
}
const SearchContext = React.createContext<SearchContextType | null>(null)

View File

@@ -0,0 +1,38 @@
import { X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useSearchContext } from "./context"
import { cn } from "@/lib/utils"
import { useTranslation } from "react-i18next";
export function FilterResetButton() {
const { filter, setFilter } = useSearchContext();
const { t } = useTranslation()
const { q, ...restFilters } = filter;
const activeFiltersCount = Object.keys(restFilters).filter(key => {
const value = restFilters[key];
if (Array.isArray(value)) return value.length > 0;
return value !== undefined && value !== null && value !== '';
}).length;
if (activeFiltersCount === 0) return null;
return (
<Button
variant="ghost"
size="sm"
onClick={() => setFilter(q ? { q } : {})}
className={cn(
"h-8 px-2 text-xs gap-1.5 font-normal",
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
)}
title={t('search_reset.tooltip')}
>
<span>{t('search_reset.label')}</span>
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-muted-foreground/20 text-[10px]">
{activeFiltersCount}
</div>
<X className="h-3 w-3" />
</Button>
);
}

View File

@@ -21,24 +21,18 @@ import { Card, CardContent } from '@/components/ui/card';
import { FixedHeader } from '@/components/layout/fixed-header';
import { Main } from '@/components/layout/main';
import { useSearchMessages } from '@/hooks/use-search-messages';
import { SearchFormDialog } from './search-form';
import { EnvelopeListPagination } from '@/components/pagination';
import { MailList } from './mail-list';
import React from 'react';
import { EmailEnvelope } from '@/api';
import { Filter, SearchIcon } from 'lucide-react';
import { MailDisplayDrawer } from './mail-display-dialog';
import { EnvelopeDeleteDialog } from './delete-dialog';
import SearchProvider, { SearchDialogType } from './context';
import useDialogState from '@/hooks/use-dialog-state';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { EnvelopeTags } from './tag-facet';
import { EditTagsDialog } from './add-tag-dialog';
import { useTranslation } from 'react-i18next';
import Logo from '@/assets/logo.svg'
import { RestoreMessageDialog } from './restore-message-dialog';
import { MailListTable } from './mail-list-table';
import { SortingState } from '@tanstack/react-table';
export default function Search() {
const { t } = useTranslation()
@@ -47,20 +41,21 @@ export default function Search() {
const [toDelete, setToDelete] = React.useState<Map<number, Set<number>>>(new Map());
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
const {
emails,
total,
totalPages,
isLoading,
isFetching,
page,
pageSize,
setPage,
setPageSize,
onSubmit,
reset,
filter
setSortBy,
setSortOrder,
filter,
setFilter
} = useSearchMessages();
const handleSetPageSize = (pageSize: number) => {
@@ -68,12 +63,6 @@ export default function Search() {
setPageSize(pageSize)
}
const handleReset = () => {
reset();
setSelectedTags([]);
};
const handleTagToggle = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag)
@@ -86,45 +75,27 @@ export default function Search() {
<>
<FixedHeader />
<Main>
<SearchProvider value={{ open, setOpen, currentEnvelope: selectedEnvelope, selectedTags, setCurrentEnvelope: setSelectedEnvelope, toDelete, setToDelete, selected, setSelected }}>
<SearchProvider
value={{
open,
setOpen,
currentEnvelope: selectedEnvelope,
selectedTags,
setCurrentEnvelope: setSelectedEnvelope,
toDelete,
setToDelete,
selected,
setSelected,
sorting,
setSorting,
filter,
setFilter,
handleTagToggle
}}
>
<div className="mx-auto w-full px-4">
<div className="mb-4 lg:hidden">
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="mr-2 h-4 w-4" />
{t('search.tagFilter')}
{selectedTags.length > 0 && ` (${selectedTags.length})`}
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80">
<SheetHeader>
<SheetTitle>{t('search.tagFilter')}</SheetTitle>
</SheetHeader>
<div className="mt-6">
<EnvelopeTags
selectedTags={selectedTags}
onTagToggle={handleTagToggle}
/>
</div>
</SheetContent>
</Sheet>
</div>
<div className="flex gap-6">
<aside className="hidden lg:block w-64 flex-shrink-0">
<div className="rounded-lg border bg-card p-4">
<EnvelopeTags
selectedTags={selectedTags}
onTagToggle={handleTagToggle}
/>
</div>
</aside>
<div className="flex-1 min-w-0 space-y-4">
<Button size="sm" onClick={() => setOpen("search-form")}>
<SearchIcon className="mr-2 h-4 w-4" />
{t('common.search')}
</Button>
{isLoading && (
<Card>
<CardContent className="py-12">
@@ -136,31 +107,16 @@ export default function Search() {
</Card>
)}
{total === 0 && <div className="flex h-[750px] 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('search.noEmailsFound')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{Object.keys(filter).length === 0
? t('search.startSearching')
: t('search.adjustSearch')}
</p>
</div>
</div>}
{total > 0 && <ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
<MailList
isLoading={isLoading}
items={emails}
onEnvelopeChanged={(envelope) => {
setOpen('display');
setSelectedEnvelope(envelope);
}}
/>
</ScrollArea>}
<MailListTable
isLoading={isLoading}
items={emails}
onEnvelopeChanged={(envelope) => {
setOpen('display');
setSelectedEnvelope(envelope);
}}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
/>
{total > 0 && <EnvelopeListPagination
totalItems={total}
hasNextPage={() => page < totalPages}
@@ -191,13 +147,6 @@ export default function Search() {
onOpenChange={() => setOpen('edit-tags')}
/>
<SearchFormDialog
key='search-form-dialog'
onSubmit={onSubmit} isLoading={isLoading || isFetching} reset={handleReset}
open={open === 'search-form'}
onOpenChange={() => setOpen('search-form')}
/>
<RestoreMessageDialog
key='restore-mail-dialog'
open={open === 'restore'}
@@ -207,4 +156,4 @@ export default function Search() {
</Main>
</>
);
}
}

View File

@@ -0,0 +1,333 @@
//
// 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 { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { format, formatDistanceToNow } from "date-fns"
import { MessageSquareText, Paperclip } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox"
import { EmailEnvelope } from "@/api"
import { useSearchContext } from "./context"
import { MailBulkActions } from "./bulk-actions"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
import { ColumnDef } from "@tanstack/react-table"
import LongText from "@/components/long-text"
import { DataTableColumnHeader } from "./table/data-table-column-header"
import { SearchTable } from "./table/table"
import { DataTableRowActions } from "./table/data-table-row-actions"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { DataTableToolbar } from "./table/toolbar"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
interface MailListProps {
items: EmailEnvelope[]
isLoading: boolean
onEnvelopeChanged: (envelope: EmailEnvelope) => void
setSortBy: (sortBy: "DATE" | "SIZE") => void
setSortOrder: (value: "desc" | "asc") => void
}
export function MailListTable({
items,
isLoading,
onEnvelopeChanged,
setSortBy,
setSortOrder
}: MailListProps) {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
const { selected, setSelected } = useSearchContext()
const columns: ColumnDef<EmailEnvelope>[] = [
{
accessorKey: "id",
header: () => (
<Checkbox
checked={
totalSelected === items.length && items.length > 0
? true
: totalSelected > 0
? "indeterminate"
: false
}
onCheckedChange={handleToggleAll}
className="h-4 w-4"
/>
),
cell: ({ row }) => (
<Checkbox
checked={hasSelected(row.original.account_id, row.original.id)}
onCheckedChange={() => toggleSelected(row.original.account_id, row.original.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0"
/>
),
meta: { className: 'text-left text-sm' },
minSize: 25,
maxSize: 25,
},
{
accessorKey: "account_email",
header: t('search.account'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.account_email}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 156,
},
{
accessorKey: "mailbox_name",
header: t('search.mailbox'),
cell: ({ row }) => {
const mailbox = row.original.mailbox_name
const tags = row.original.tags ?? []
if (!mailbox) return null
const visible = tags.slice(0, 3)
const rest = tags.length - visible.length
const fullTags = tags.join(' · ')
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col leading-tight max-w-[130px] cursor-default">
<span className="text-xs truncate">
{mailbox}
</span>
{visible.length > 0 && (
<span className="text-[10px] text-primary/80 truncate">
{visible.join(' · ')}
{rest > 0 && ` · +${rest}`}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-xs"
>
<div className="text-xs font-medium mb-1">
{mailbox}
</div>
<div className="text-[11px] text-muted-foreground break-words">
{fullTags}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
},
meta: { className: 'text-left text-xs' },
minSize: 116,
maxSize: 116,
},
{
accessorKey: "from",
header: t('search.from'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.from}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 156,
},
{
accessorKey: "to",
header: t('search.to'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.to.join(", ")}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 156,
},
{
accessorKey: "subject",
header: t('search.subject'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 450,
maxSize: 456,
},
{
id: "text_preview",
header: () => null,
cell: ({ row }) => {
const text = row.original.text
if (!text) return null
return (
<HoverCard openDelay={200} closeDelay={150}>
<HoverCardTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-primary transition"
onClick={(e) => e.stopPropagation()}
>
<MessageSquareText size={16} />
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
className="max-w-[520px] max-h-[420px] overflow-auto whitespace-pre-wrap text-xs leading-relaxed"
>
{text}
</HoverCardContent>
</HoverCard>
)
},
meta: { className: "text-center max-w-[80px]" },
minSize: 36,
maxSize: 36,
enableSorting: false,
},
{
id: "attachment_count",
header: () => <Paperclip size={16} />,
cell: ({ row }) => <span className='text-xs'>{(row.original.attachments ?? []).length}</span>,
meta: { className: 'text-left text-xs' },
minSize: 40,
maxSize: 40
},
{
accessorKey: 'size',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('search.size')} />
),
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
meta: { className: 'text-left text-xs' },
minSize: 100,
maxSize: 100,
},
{
accessorKey: 'date',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('search.date')} />
),
cell: ({ row }) => {
const date = new Date(row.original.date)
const title = format(date, 'yyyy-MM-dd HH:mm:ss')
return (
<Tooltip>
<TooltipTrigger asChild>
<span className='text-xs whitespace-nowrap'>
{formatDistanceToNow(date, { addSuffix: true, locale })}
</span>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
)
},
meta: { className: 'text-left text-xs' },
minSize: 100,
maxSize: 100,
},
{
id: 'actions',
header: t('users.columns.actions'),
cell: DataTableRowActions,
meta: { className: 'text-left text-xs' },
minSize: 50,
maxSize: 60,
},
]
const handleToggleAll = () => {
const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
if (total === items.length && items.length > 0) {
setSelected(new Map())
} else {
setSelected(prev => {
const next = new Map(prev)
for (const item of items) {
const set = new Set(next.get(item.account_id) || [])
set.add(item.id)
next.set(item.account_id, set)
}
return next
})
}
}
const toggleSelected = (accountId: number, mailId: number) => {
setSelected(prev => {
const next = new Map(prev)
const set = new Set(next.get(accountId) || [])
if (set.has(mailId)) {
set.delete(mailId)
if (set.size === 0) next.delete(accountId)
else next.set(accountId, set)
} else {
set.add(mailId)
next.set(accountId, set)
}
return next
})
}
const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
const hasSelected = (accountId: number, mailId: number) => selected.get(accountId)?.has(mailId) ?? false
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-2.5 w-16" />
</div>
))}
</div>
)
}
return (
<>
<SearchTable
data={items}
columns={columns}
onRowClick={(e, row) => {
const target = e.target as HTMLElement
if (target.closest('input[type="checkbox"], button')) return
onEnvelopeChanged(row.original)
}}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
>
{(table) => {
return <DataTableToolbar table={table} />
}}
</SearchTable>
{totalSelected > 0 && <MailBulkActions />}
</>
)
}

View File

@@ -147,7 +147,7 @@ export function MailList({
/>
<span className="text-xs text-muted-foreground">
{totalSelected > 0
? `${totalSelected} ${t('common.selected')}`
? `${t('search.bulkActions.selected', { count: totalSelected })}`
: t('common.selectAll')}
</span>
</div>

View File

@@ -19,7 +19,7 @@
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore } from 'lucide-react';
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -84,6 +84,34 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines
);
};
const getFileConfig = (mimeType: string) => {
const type = mimeType.toLowerCase();
if (type.includes('pdf')) {
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
}
if (type.includes('image/')) {
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
}
if (type.includes('audio/')) {
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
}
if (type.includes('video/')) {
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
}
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
}
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
}
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
}
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
};
export function MailMessageView({
envelope,
showActions = true,
@@ -91,7 +119,7 @@ export function MailMessageView({
showHeader = true
}: MailMessageViewProps) {
const { t } = useTranslation()
const { setToDelete, setOpen } = useSearchContext();
const { setToDelete, setOpen, setSelected } = useSearchContext();
const [content, setContent] = useState<string | null>(null);
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
@@ -184,7 +212,6 @@ export function MailMessageView({
return (
<div className="flex flex-col h-full">
{/* Header Info */}
{showHeader && <div className="grid gap-1 text-xs">
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.account')}:</span>
@@ -216,7 +243,7 @@ export function MailMessageView({
</div>
)}
</div>}
{/* Action Bar */}
{showActions && (
<>
<div className="flex items-center mt-2 space-x-2">
@@ -253,11 +280,26 @@ export function MailMessageView({
</TooltipTrigger>
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelected(new Map())
setOpen('restore')
}}
>
<Upload className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('restore_message.restore_to_imap', 'Restore Mail')}</TooltipContent>
</Tooltip>
</div>
</>
)}
{showAttachments && <Separator className="my-2" />}
{/* Attachments */}
{showAttachments && (
<div className="mb-2">
{loading ? (
@@ -265,13 +307,27 @@ export function MailMessageView({
) : attachments && attachments.length > 0 ? (
(() => {
const nonInline = attachments.filter((a) => !a.inline);
return nonInline.length > 0 ? (
<div className="space-y-2">
{nonInline.map((attachment, i) => (
<div key={i} className="flex items-center">
<div className="flex items-center space-x-8">
<span className="truncate text-xs">{attachment.filename}</span>
<span className="text-xs px-2 py-1 rounded">[{attachment.file_type}]</span>
{nonInline.map((attachment, i) => {
const { icon, color } = getFileConfig(attachment.file_type);
return <div key={i} className="flex items-center">
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
<div className={`flex-shrink-0 ${color}`}>
{icon}
</div>
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
<span
className="truncate text-xs font-medium text-foreground/90"
title={attachment.filename}
>
{attachment.filename}
</span>
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
{attachment.file_type.split('/').pop()?.toUpperCase()}
</span>
</div>
</div>
<div className="flex items-center space-x-4 ml-auto">
<span className="text-gray-500 text-xs shrink-0">
@@ -290,7 +346,7 @@ export function MailMessageView({
)}
</div>
</div>
))}
})}
</div>
) : (
<span className="text-gray-500 text-xs italic">
@@ -304,7 +360,6 @@ export function MailMessageView({
</div>
)}
{showAttachments && <Separator className="mb-2" />}
{/* Content */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex justify-center items-center py-8">

View File

@@ -0,0 +1,263 @@
import * as React from 'react'
import { ChevronDown, Folders, X } from 'lucide-react'
import { useQueries } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { useSearchContext } from './context'
export function MailboxPopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const { minimalList = [] } = useMinimalAccountList()
const [search, setSearch] = React.useState('')
const accountIds: number[] = filter.account_ids ?? []
const selectedMailboxIds: number[] = filter.mailbox_ids ?? []
const { mailboxes, isLoading } = useQueries({
queries: accountIds.map(id => ({
queryKey: ['search-mailboxes', id],
queryFn: () => list_mailboxes(id, false),
enabled: accountIds.length > 0,
})),
combine: results => ({
mailboxes: results.flatMap(r => r.data ?? []),
isLoading: results.some(r => r.isLoading),
}),
})
const toggleMailbox = (id: number) => {
setFilter(prev => {
const next = { ...prev }
const set = new Set<number>(next.mailbox_ids ?? [])
set.has(id) ? set.delete(id) : set.add(id)
const ids = Array.from(set)
if (ids.length === 0) delete next.mailbox_ids
else next.mailbox_ids = ids
return next
})
}
const clearAllMailboxes = () => {
setFilter(prev => {
const next = { ...prev }
delete next.mailbox_ids
return next
})
}
const grouped = React.useMemo(() => {
const q = search.trim().toLowerCase()
const map = new Map<number, MailboxData[]>()
for (const mb of mailboxes) {
if (q && !mb.name.toLowerCase().includes(q)) continue
if (!map.has(mb.account_id)) map.set(mb.account_id, [])
map.get(mb.account_id)!.push(mb)
}
for (const list of map.values()) {
list.sort((a, b) => {
const aSel = selectedMailboxIds.includes(a.id)
const bSel = selectedMailboxIds.includes(b.id)
if (aSel && !bSel) return -1
if (!aSel && bSel) return 1
return a.name.localeCompare(b.name)
})
}
return Array.from(map.entries())
}, [mailboxes, search, selectedMailboxIds])
const defaultOpen = grouped
.filter(([, boxes]) =>
boxes.some(m => selectedMailboxIds.includes(m.id))
)
.map(([id]) => id.toString())
const getAccountEmail = (id: number) =>
minimalList.find(a => a.id === id)?.email ?? ''
const disabled = accountIds.length === 0
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
disabled={disabled}
className={cn(
'h-8 rounded-none px-3 gap-1.5',
selectedMailboxIds.length > 0 &&
'bg-primary/10 text-primary'
)}
>
<Folders className="h-4 w-4" />
{t('search_mailbox.label')}
{selectedMailboxIds.length > 0 && (
<span className="ml-1 text-xs opacity-70">
{selectedMailboxIds.length}
</span>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="min-w-[260px] w-fit max-w-[620px] p-1">
<div className="p-1 pb-2">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('search_mailbox.search_placeholder')}
className="h-8 text-sm"
/>
</div>
{selectedMailboxIds.length > 0 && (
<div className="px-1 pb-2">
<Button
variant="ghost"
size="sm"
onClick={clearAllMailboxes}
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive transition-colors"
>
<X className="mr-2 h-3.5 w-3.5" />
{t('search_mailbox.clear_mailboxes')} ({selectedMailboxIds.length})
</Button>
</div>
)}
<ScrollArea className="h-96 p-1">
{disabled ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('search_mailbox.select_account_first')}
</p>
) : isLoading ? (
<div className="space-y-2 p-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-4 rounded bg-muted animate-pulse"
/>
))}
</div>
) : grouped.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('search_mailbox.no_mailbox_found')}
</p>
) : (
<Accordion
type="multiple"
defaultValue={defaultOpen}
className="space-y-1"
>
{grouped.map(([accountId, boxes]) => {
const selectedCount = boxes.filter(b =>
selectedMailboxIds.includes(b.id)
).length
return (
<AccordionItem
key={accountId}
value={accountId.toString()}
>
<AccordionTrigger className="text-xs px-2 py-1.5">
<span className="truncate">
{getAccountEmail(accountId)}
</span>
{selectedCount > 0 && (
<span className="ml-2 text-[10px] text-primary">
{selectedCount}
</span>
)}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-0.5">
{boxes.map(mailbox => {
const checked =
selectedMailboxIds.includes(mailbox.id)
return (
<TooltipProvider key={mailbox.id}>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() =>
toggleMailbox(mailbox.id)
}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
'hover:bg-accent transition-colors',
checked &&
'bg-primary/10 text-primary'
)}
>
<Checkbox
checked={checked}
onCheckedChange={() =>
toggleMailbox(mailbox.id)
}
onClick={e =>
e.stopPropagation()
}
/>
<span className="text-xs truncate">
{mailbox.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<div className="text-sm break-all">
{mailbox.name}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
})}
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
)}
</ScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,194 @@
import * as React from "react"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { ListFilter } from "lucide-react"
import { useTranslation } from "react-i18next"
import { useSearchContext } from "./context"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
const SIZES = {
tiny: { min: undefined, max: 15 * 1024 },
small: { min: undefined, max: 2 * 1024 * 1024 },
medium: { min: 2 * 1024 * 1024, max: 10 * 1024 * 1024 },
large: { min: 10 * 1024 * 1024, max: 20 * 1024 * 1024 },
huge: { min: 20 * 1024 * 1024, max: undefined },
};
const getPresetFromSize = (min?: number, max?: number) => {
if (min === SIZES.huge.min) return 'huge';
if (min === SIZES.large.min && max === SIZES.large.max) return 'large';
if (min === SIZES.medium.min && max === SIZES.medium.max) return 'medium';
if (!min && max === SIZES.small.max) return 'small';
if (!min && max === SIZES.tiny.max) return 'tiny';
return 'any';
};
export function MoreFiltersPopover() {
const { t } = useTranslation();
const { filter, setFilter } = useSearchContext();
const [open, setOpen] = React.useState(false);
const [localState, setLocalState] = React.useState({
attachment_name: filter?.attachment_name || '',
message_id: filter?.message_id || '',
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
has_attachment: filter?.has_attachment || false
});
React.useEffect(() => {
if (open) {
setLocalState({
attachment_name: filter?.attachment_name || '',
message_id: filter?.message_id || '',
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
has_attachment: filter?.has_attachment || false
});
}
}, [open, filter]);
const handleApply = () => {
setFilter(prev => {
const next = { ...prev };
if (localState.attachment_name) next.attachment_name = localState.attachment_name;
else delete next.attachment_name;
if (localState.message_id) next.message_id = localState.message_id;
else delete next.message_id;
if (localState.has_attachment) next.has_attachment = true;
else delete next.has_attachment;
const range = SIZES[localState.size_preset as keyof typeof SIZES] || { min: undefined, max: undefined };
if (range.min) next.min_size = range.min; else delete next.min_size;
if (range.max) next.max_size = range.max; else delete next.max_size;
return next;
});
setOpen(false);
};
const activeCount = [
filter?.attachment_name,
filter?.min_size,
filter?.max_size,
filter?.message_id,
filter?.has_attachment
].filter(Boolean).length;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-8 gap-2 px-3 rounded-none border-l-0",
activeCount > 0 && "bg-primary/10 border-primary text-primary"
)}
>
<ListFilter className="h-3.5 w-3.5" />
<span className="text-xs">{t('search_more.trigger_label')}</span>
{activeCount > 0 && (
<Badge className="ml-1 h-4 px-1 text-[10px] bg-primary text-primary-foreground border-none rounded-sm">
{activeCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium">{t('search_more.title')}</h4>
{activeCount > 0 && (
<Button
variant="ghost"
className="h-auto p-0 text-[10px] text-muted-foreground hover:text-destructive"
onClick={() => {
setFilter(prev => {
const next = { ...prev };
delete next.attachment_name;
delete next.min_size;
delete next.max_size;
delete next.message_id;
delete next.has_attachment;
return next;
});
setOpen(false);
}}
>
{t('search_more.reset')}
</Button>
)}
</div>
<Separator />
<div className="flex items-center space-x-2 px-1">
<Checkbox
id="has_attachment"
checked={localState.has_attachment}
onCheckedChange={(checked) =>
setLocalState(prev => ({ ...prev, has_attachment: checked as boolean }))
}
/>
<Label
htmlFor="has_attachment"
className="text-xs font-normal cursor-pointer select-none"
>
{t('search_more.has_attachment')}
</Label>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label>
<Input
className="h-8 text-xs"
value={localState.attachment_name}
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))}
placeholder={t('search_more.attachment_name_placeholder')}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label>
<Select
value={localState.size_preset}
onValueChange={(v) => setLocalState(prev => ({ ...prev, size_preset: v }))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(SIZES).concat('any').map((key) => (
<SelectItem key={key} className="text-xs" value={key}>
{t(`search_more.size_presets.${key}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('search_more.message_id_label')}</Label>
<Input
className="h-8 text-xs"
value={localState.message_id}
onChange={(e) => setLocalState(prev => ({ ...prev, message_id: e.target.value }))}
/>
<p className="text-[10px] text-muted-foreground opacity-70 leading-tight">
{t('search_more.message_id_description')}
</p>
</div>
<Button size="sm" className="w-full h-8 text-xs mt-2" onClick={handleApply}>
{t('search_more.apply')}
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -25,6 +25,52 @@ import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { ToastAction } from '@/components/ui/toast'
import { useSearchContext } from './context'
import { EmailEnvelope } from '@/api'
function MessageSummary({ envelope, t }: { envelope: EmailEnvelope, t: (key: string) => string }) {
return (
<div className="mt-3 rounded-md border bg-muted/20 p-3 text-sm overflow-hidden">
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1.5">
<span className="font-medium text-muted-foreground">{t("mail.subject")}:</span>
<div className="break-words font-medium">
{envelope.subject || <em className="italic opacity-70">(No subject)</em>}
</div>
<span className="font-medium text-muted-foreground">{t("mail.from")}:</span>
<div className="break-all text-foreground/90">
{envelope.from}
</div>
{envelope.to?.length > 0 && (
<>
<span className="font-medium text-muted-foreground">{t("mail.to")}:</span>
<div className="break-all text-foreground/90">
{envelope.to.slice(0, 2).join(", ")}
{envelope.to.length > 2 && " …"}
</div>
</>
)}
<span className="font-medium text-muted-foreground">{t("mail.date")}:</span>
<div className="text-foreground/90">
{new Date(envelope.date).toLocaleString()}
</div>
{envelope.mailbox_name && (
<>
<span className="font-medium text-muted-foreground">{t("search.mailbox")}:</span>
<div className="truncate text-foreground/90" title={envelope.mailbox_name}>
{envelope.mailbox_name}
</div>
</>
)}
</div>
</div>
);
}
interface RestoreMessageDialogProps {
open: boolean
@@ -36,12 +82,26 @@ export function RestoreMessageDialog({
onOpenChange
}: RestoreMessageDialogProps) {
const { t } = useTranslation()
const { currentEnvelope } = useSearchContext()
const { currentEnvelope, selected } = useSearchContext()
const accountsWithSelection = Array.from(selected.entries()).filter(([_, ids]) => ids.size > 0);
const selectedCount = accountsWithSelection.reduce((sum, [_, set]) => sum + set.size, 0);
const accountCount = accountsWithSelection.length;
const isBulk = selectedCount > 0;
const restoreMutation = useMutation({
mutationFn: () =>
restore_message(currentEnvelope!.account_id, [currentEnvelope!.id]),
mutationFn: async () => {
if (isBulk) {
const promises = accountsWithSelection.map(([accountId, ids]) =>
restore_message(accountId, Array.from(ids))
);
return Promise.all(promises);
} else if (currentEnvelope) {
return restore_message(currentEnvelope.account_id, [currentEnvelope.id]);
}
},
onSuccess: handleRestoreSuccess,
onError: handleRestoreError,
});
@@ -90,16 +150,37 @@ export function RestoreMessageDialog({
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={t('restore_message.title', 'Restore messages')}
desc={t(
'restore_message.desc',
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
)}
title={isBulk ? t('restore_message.bulkTitle', 'Restore multiple messages') : t('restore_message.title', 'Restore message')}
desc={<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{t(
'restore_message.desc',
'This action will append the selected messages to their corresponding mailboxes on the IMAP server.'
)}
</p>
{isBulk ? (
<div className="rounded-md bg-primary/5 border border-primary/20 p-3 text-sm">
<div className="flex justify-between items-center text-primary font-medium">
<span>{t('restore_message.summary', 'Summary')}</span>
<span className="bg-primary/10 px-2 py-0.5 rounded text-xs">
{selectedCount} {t('restore_message.messages', 'messages')}
</span>
</div>
<div className="mt-2 text-xs space-y-1 text-muted-foreground">
<p> {t('restore_message.accountsInvolved', 'Accounts involved')}: {accountCount}</p>
<p> {t('restore_message.bulkWarning', 'Messages will be restored to their original folders.')}</p>
</div>
</div>
) : (
currentEnvelope && <MessageSummary envelope={currentEnvelope} t={t} />
)}
</div>}
confirmText={t('restore_message.confirm', 'Restore')}
handleConfirm={() => restoreMutation.mutate()}
className="sm:max-w-sm"
isLoading={restoreMutation.isPending}
disabled={restoreMutation.isPending}
disabled={restoreMutation.isPending || (!isBulk && !currentEnvelope)}
/>
)
}

View File

@@ -1,499 +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 { DatePicker } from "@/components/date-picker";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ChevronDown, ChevronUp, Filter, RotateCcw } from "lucide-react";
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useState } from "react";
import { useForm } from "react-hook-form";
import { VirtualizedSelect } from "@/components/virtualized-select";
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
import { useNavigate } from "@tanstack/react-router";
import { list_mailboxes, MailboxData } from "@/api/mailbox/api";
import { useQuery } from "@tanstack/react-query";
import { useSearchContext } from "./context";
import { toast } from "@/hooks/use-toast";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useTranslation } from "react-i18next";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const getSearchFilterSchema = (t: (key: string) => string) => z.object({
text: z.string().optional().or(z.literal("")),
from: z
.string()
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
to: z
.string()
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
cc: z
.string()
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
bcc: z
.string()
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
has_attachment: z.boolean().optional(),
attachment_name: z.string().optional().or(z.literal("")),
since: z.date().optional(),
before: z.date().optional(),
account_id: z.number().optional().or(z.literal("")),
mailbox_id: z.number().optional().or(z.literal("")),
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large']).optional(),
message_id: z.string().optional().or(z.literal("")),
});
type SearchFilterForm = z.infer<ReturnType<typeof getSearchFilterSchema>>;
interface Props {
onSubmit: (values: Record<string, any>) => void,
isLoading: boolean,
reset: () => void,
open: boolean,
onOpenChange: (open: boolean) => void;
}
const isEmptyValue = (value: any): boolean => {
if (value === null || value === undefined) return true;
if (value === '') return true;
if (typeof value === 'string' && value.trim() === '') return true;
if (typeof value === 'number' && isNaN(value)) return true;
if (value === false) return true;
if (value === 0) return true;
return false;
};
const cleanEmpty = <T extends Record<string, any>>(obj: T): Partial<T> => {
return Object.fromEntries(
Object.entries(obj).filter(([_, value]) => !isEmptyValue(value))
) as Partial<T>;
};
function withSizePreset(values: Record<string, any>) {
const { size_preset, ...rest } = values;
switch (size_preset) {
case 'tiny':
return { ...rest, max_size: 15 * 1024 };
case 'small':
return { ...rest, max_size: 2 * 1024 * 1024 };
case 'medium':
return { ...rest, max_size: 20 * 1024 * 1024 };
case 'large':
return { ...rest, min_size: 20 * 1024 * 1024 };
default:
return rest;
}
}
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
const { t } = useTranslation()
const [showAdvanced, setShowAdvanced] = useState(false);
const [selectedAccountId, setSelectedAccountId] = useState<number | undefined>(undefined);
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
const { selectedTags } = useSearchContext();
const searchFilterSchema = getSearchFilterSchema(t)
const form = useForm<SearchFilterForm>({
resolver: zodResolver(searchFilterSchema),
defaultValues: {
text: "",
from: "",
to: "",
cc: "",
bcc: "",
attachment_name: "",
message_id: "",
size_preset: 'any',
has_attachment: false,
since: undefined,
before: undefined,
account_id: undefined,
mailbox_id: undefined,
},
mode: "onChange",
});
const navigate = useNavigate();
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
queryKey: ['search-account-mailboxes', `${selectedAccountId}`],
queryFn: () => list_mailboxes(selectedAccountId!, false),
enabled: !!selectedAccountId,
})
const mailboxesOptions = mailboxes?.map((mailbox: MailboxData) => ({
value: mailbox.id.toString(),
label: mailbox.name,
})) || [];
const handleSubmit = (values: Record<string, any>) => {
let cleaned = cleanEmpty(values);
const payload = withSizePreset(cleaned);
const finalPayload =
selectedTags.length > 0
? { ...payload, tags: selectedTags }
: payload;
if (Object.keys(finalPayload).length > 0) {
onSubmit(finalPayload);
} else {
toast({
title: t('search.pleaseSelectAtLeastOne'),
});
}
}
const handleClear = () => {
form.reset({
text: "",
from: "",
to: "",
cc: "",
bcc: "",
has_attachment: false,
attachment_name: "",
since: undefined,
before: undefined,
account_id: undefined,
mailbox_id: undefined,
size_preset: 'any',
message_id: "",
});
setSelectedAccountId(undefined);
}
return (<Sheet
open={open}
onOpenChange={onOpenChange}
>
<SheetContent className='w-full md:max-w-4xl mx-auto'>
<SheetHeader className="p-4 pb-3 border-b shrink-0">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2">
{t('search.searchArchivedEmails')}
</SheetTitle>
</div>
</SheetHeader>
<SheetDescription>
{t('search.fullTextMultiAccount')}
</SheetDescription>
<Form {...form}>
<form id="email-search-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField
control={form.control}
name="account_id"
render={({ field }) => (
<FormItem className="min-w-[180px]">
<div className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.account')}:</FormLabel>
<FormControl className="flex-1">
<VirtualizedSelect
options={accountsOptions}
isLoading={accountsIsLoading}
onSelectOption={(values) => {
const account_id = parseInt(values[0], 10);
setSelectedAccountId(account_id);
field.onChange(account_id);
}}
value={field.value?.toString() ?? ""}
placeholder={t('search.selectAccount')}
className="h-10 w-full"
noItemsComponent={
<div className="p-2">
<p className="text-xs">{t('search.noActiveEmailAccount')}</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: "/accounts" })}
>
{t('search.addEmailAccount')}
</Button>
</div>
}
/>
</FormControl>
</div>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mailbox_id"
render={({ field }) => (
<FormItem className="min-w-[180px]">
<div className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}:</FormLabel>
<FormControl className="flex-1">
<VirtualizedSelect
options={mailboxesOptions}
isLoading={isMailboxesLoading}
onSelectOption={(values) => field.onChange(parseInt(values[0], 10))}
value={field.value?.toString() ?? ""}
placeholder={t('search.selectMailbox')}
className="h-10 w-full"
noItemsComponent={
<div className="p-2">
<p className="text-xs">
{t('search.noMailboxSelectAccount')}
</p>
</div>
}
/>
</FormControl>
</div>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
</div>
<div className="flex flex-col sm:flex-row gap-3 items-stretch">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder={t('search.searchInSubjectBody')}
className="h-11 text-base"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 sm:ml-auto sm:self-center">
<Button type="submit" className="h-11 px-6" disabled={isLoading}>
{isLoading ? t('search.searchingButton') : <>{t('search.searchButton')}</>}
</Button>
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<Filter className="w-4 h-4 mr-1" />
{t('search.advanced')}
{showAdvanced ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</Button>
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => { handleClear(); reset(); }}
>
<RotateCcw className="w-4 h-4 mr-1" />
{t('search.clear')}
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm">
<FormField
control={form.control}
name="since"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.since')}</FormLabel>
<FormControl>
<DatePicker
placeholder={t('search.selectDate')}
selected={field.value}
onSelect={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="before"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.before')}</FormLabel>
<FormControl>
<DatePicker
placeholder={t('search.selectDate')}
selected={field.value}
onSelect={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="has_attachment"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<Checkbox
id="attach"
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="attach" className="cursor-pointer text-sm font-normal">
{t('search.hasAttachment')}
</FormLabel>
</FormItem>
)}
/>
</div>
</div>
<div>
{showAdvanced && <Accordion type="multiple" className="space-y-3">
{/* Sender & Recipients */}
<AccordionItem value="people">
<AccordionTrigger className="text-sm">
{t('search.sender')} / {t('search.recipient')}
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-2">
{(['from', 'to', 'cc', 'bcc'] as const).map((key) => (
<FormField
key={key}
control={form.control}
name={key}
render={({ field }) => (
<FormItem>
<FormLabel className="capitalize text-xs">
{key === 'from' ? t('search.from') : key === 'to' ? t('search.to') : key === 'cc' ? t('search.cc') : t('search.bcc')}:
</FormLabel>
<FormControl>
<Input
placeholder={`${key}@example.com`}
className="h-9"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="attachment">
<AccordionTrigger className="text-sm">
{t('search.attachmentsSize')}
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
<FormField
control={form.control}
name="attachment_name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">{t('search.attachmentName')}:</FormLabel>
<FormControl>
<Input placeholder="invoice.pdf" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="size_preset"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">
{t('search.size')}
</FormLabel>
<Select
value={field.value}
onValueChange={(value) => field.onChange(value)}
>
<FormControl>
<SelectTrigger className="h-9">
<SelectValue placeholder={t('search.any')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="any">{t('search.any')}</SelectItem>
<SelectItem value="tiny">{t('search.tiny')}</SelectItem>
<SelectItem value="small">{t('search.small')}</SelectItem>
<SelectItem value="medium">{t('search.medium')}</SelectItem>
<SelectItem value="large">{t('search.large')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
<FormDescription className="text-xs">
{t('search.sizeDescription')}
</FormDescription>
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="ids">
<AccordionTrigger className="text-sm">
{t('search.messageId')}
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
<FormField
control={form.control}
name="message_id"
render={({ field }) => (
<FormItem className="col-span-full">
<FormControl>
<Input placeholder="<abc123@example.com>" className="h-9" {...field} />
</FormControl>
<FormDescription className="text-xs">
{t('search.originalMessageIdHeader')}
</FormDescription>
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>}
</div>
</form>
</Form>
</SheetContent>
</Sheet>);
}

View File

@@ -0,0 +1,83 @@
//
// 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 {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
} from '@radix-ui/react-icons'
import { Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<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>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className=' h-8 data-[state=open]:bg-accent'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ml-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ml-2 h-4 w-4' />
) : (
<CaretSortIcon className='ml-2 h-4 w-4' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.asc')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.desc')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -0,0 +1,122 @@
//
// 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 { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { MoreVertical, TagIcon, Trash2, Upload } from 'lucide-react'
import { EmailEnvelope } from '@/api'
import { useSearchContext } from '../context'
interface DataTableRowActionsProps {
row: Row<EmailEnvelope>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentEnvelope, setSelected, setToDelete } = useSearchContext()
const { t } = useTranslation()
const toggleToDelete = (accountId: number, mailId: number) => {
setToDelete(prev => {
const next = new Map(prev)
const set = new Set(next.get(accountId) || [])
if (set.has(mailId)) {
set.delete(mailId)
if (set.size === 0) next.delete(accountId)
else next.set(accountId, set)
} else {
set.add(mailId)
next.set(accountId, set)
}
return next
})
}
const handleDelete = (envelope: EmailEnvelope) => {
setToDelete(new Map())
toggleToDelete(envelope.account_id, envelope.id)
setOpen("delete")
}
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<MoreVertical size={10} />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setCurrentEnvelope(row.original)
setOpen("edit-tags")
}}
>
{t('search.editTag')}
<DropdownMenuShortcut>
<TagIcon size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setCurrentEnvelope(row.original)
setSelected(new Map())
setOpen("restore")
}}
>
{t('restore_message.restore_to_imap', 'Restore Mail')}
<DropdownMenuShortcut>
<Upload size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original)
}}
className='!text-red-500'
>
{t('common.delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}

View File

@@ -0,0 +1,168 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useState, MouseEvent as ReactMouseEvent, useEffect } from 'react'
import {
ColumnDef,
ColumnFiltersState,
Row,
RowData,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { type Table } from '@tanstack/react-table'
import {
Table as ShadcnTable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useTranslation } from 'react-i18next'
import { EmailEnvelope } from '@/api'
import { cn } from '@/lib/utils'
import { useSearchContext } from '../context'
import { ScrollArea } from '@/components/ui/scroll-area'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
className: string
}
}
interface DataTableProps {
columns: ColumnDef<EmailEnvelope>[]
data: EmailEnvelope[]
onRowClick: (e: ReactMouseEvent<HTMLTableRowElement, MouseEvent>, row: Row<EmailEnvelope>) => void
setSortBy: (sortBy: "DATE" | "SIZE") => void
setSortOrder: (value: "desc" | "asc") => void
children?: (table: Table<EmailEnvelope>) => React.ReactNode
}
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) {
const { sorting, setSorting } = useSearchContext()
const { t } = useTranslation()
const [rowSelection, setRowSelection] = useState({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
useEffect(() => {
const [value] = sorting
setSortBy(value.id.toUpperCase() as "DATE" | "SIZE")
setSortOrder(value.desc ? "desc" : "asc")
}, [sorting])
const table = useReactTable({
data,
columns,
state: {
sorting,
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
return (
<div className="flex flex-1 flex-col gap-0.5">
{children && (<>{children(table)}</>)}
<ScrollArea className='h-[calc(100vh-15rem)] rounded-md border' orientation='both'>
<ShadcnTable>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='group/row'>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={header.column.columnDef.meta?.className ?? ''}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn("group/row cursor-pointer transition-colors hover:bg-accent/50")}
onClick={(e) => onRowClick(e, row)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cell.column.columnDef.meta?.className ?? ''}
style={{
width: cell.column.columnDef.size,
minWidth: cell.column.columnDef.minSize,
maxWidth: cell.column.columnDef.maxSize
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('common.table.noResults')}
</TableCell>
</TableRow>
)}
</TableBody>
</ShadcnTable>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { type Table } from '@tanstack/react-table'
import { DataTableViewOptions } from './view-options'
import { TagFilterPopover } from '../tag-filter-popover'
import { AccountMailboxFilter } from '../account-mailbox-filter'
import { TimePopover } from '../time-popover'
import { MailFilterPopover } from '../contact-popover'
import { TextSearchInput } from '../text-search-input'
import { MoreFiltersPopover } from '../more-filters-popover'
import { FilterResetButton } from '../filter-reset'
type DataTableToolbarProps<TData> = {
table: Table<TData>
}
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
return (
<div className="flex flex-col gap-1 px-1 py-1 lg:flex-row lg:items-center lg:gap-1">
<div className="flex-1">
<TextSearchInput />
</div>
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap lg:justify-end">
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
<TagFilterPopover />
<AccountMailboxFilter />
<MailFilterPopover />
<TimePopover />
<MoreFiltersPopover />
<FilterResetButton />
</div>
<div className="flex-shrink-0 ml-auto lg:ml-0">
<DataTableViewOptions table={table} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import React from 'react'
import { useTranslation } from 'react-i18next'
type DataTableViewOptionsProps<TData> = {
table: Table<TData>
}
const defaultColumns = (t: (key: string) => string) => [
{ label: t('search.account'), value: "account_email" },
{ label: t('search.mailbox'), value: "mailbox_name" },
{ label: t('search.from'), value: "from" },
{ label: t('search.to'), value: "to" },
{ label: t('search.subject'), value: "subject" },
{ label: t('search.size'), value: "size" },
{ label: t('search.date'), value: "date" },
]
export function DataTableViewOptions<TData>({
table,
}: DataTableViewOptionsProps<TData>) {
const { t } = useTranslation()
const columnLabels = React.useMemo(() => {
return Object.fromEntries(
defaultColumns(t).map(col => [col.value, col.label])
)
}, [t]);
const visibleColumnKeys = React.useMemo(() => {
return new Set(defaultColumns(t).map(c => c.value))
}, [t])
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='ms-auto hidden h-8 lg:flex rounded-none'
>
<MixerHorizontalIcon className='size-4' />
{t('search_view.button_label')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[150px]'>
<DropdownMenuLabel className='text-xs'>{t('search_view.menu_title')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(column => visibleColumnKeys.has(column.id))
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize text-xs'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{columnLabels[column.id] ?? column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,127 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, ChevronUp, Tag } from 'lucide-react';
import React from 'react';
import { useAvailableTags } from '@/hooks/use-available-tags';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label';
import { useTranslation } from 'react-i18next';
interface EnvelopeTagsProps {
selectedTags: string[];
onTagToggle: (tag: string) => void;
}
export function EnvelopeTags({ selectedTags, onTagToggle }: EnvelopeTagsProps) {
const { t } = useTranslation()
const [open, setOpen] = React.useState(true);
const {
tagsCount: tagsCount = [],
isLoading: tagsIsLoading,
} = useAvailableTags();
const sortedTags = React.useMemo(() => {
return [...tagsCount].sort((a, b) => b.count - a.count);
}, [tagsCount]);
if (tagsIsLoading) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-1.5">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
<div className="h-4 w-4 bg-muted animate-pulse rounded" />
<div className="h-4 flex-1 bg-muted animate-pulse rounded" />
<div className="h-5 w-10 bg-muted animate-pulse rounded" />
</div>
))}
</div>
</div>
);
}
return (
<Collapsible open={open} onOpenChange={setOpen} className="space-y-2">
<CollapsibleTrigger className="flex w-full items-center justify-between text-sm font-medium hover:text-primary transition-colors">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4" />
{t('mail.tags')}
{selectedTags.length > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-xs">
{selectedTags.length}
</Badge>
)}
</div>
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0">
{sortedTags.length === 0 ? (
<p className="py-2 pl-2 text-sm text-muted-foreground">{t('mail.noTagsYet')}</p>
) : (
<ScrollArea className="h-[45rem] w-full pr-4 -mr-4">
{sortedTags.map(({ tag: facet, count }) => {
const checked = selectedTags.includes(facet);
const id = `tag-${facet}`;
return (
<div
key={facet}
className="flex items-center gap-3 px-2 py-0.5 hover:bg-accent/80 rounded-md transition-colors cursor-pointer group"
onClick={() => onTagToggle(facet)}
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={() => onTagToggle(facet)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
/>
<Label
htmlFor={id}
className="flex-1 max-w-[140px] lg:max-w-[120px] cursor-pointer truncate text-sm font-medium"
title={facet}
>
{facet}
</Label>
<div className="shrink-0 ml-2">
<Badge
variant="secondary"
className="h-5 px-1.5 text-xs font-medium min-w-[1.75rem] text-center"
>
{count}
</Badge>
</div>
</div>
);
})}
</ScrollArea>
)}
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -0,0 +1,193 @@
import * as React from 'react'
import { Tag, ChevronDown, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useAvailableTags } from '@/hooks/use-available-tags'
import { cn } from '@/lib/utils'
import { useSearchContext } from './context'
export function TagFilterPopover() {
const { t } = useTranslation()
const [search, setSearch] = React.useState('')
const { filter, setFilter } = useSearchContext()
const selectedTags = (filter?.tags as string[]) || []
const {
tagsCount = [],
isLoading,
} = useAvailableTags()
const handleTagToggle = (tag: string) => {
setFilter(prev => {
const next = { ...prev }
const currentTags = (next.tags as string[]) || []
const isSelected = currentTags.includes(tag)
const nextTags = isSelected
? currentTags.filter(t => t !== tag)
: [...currentTags, tag]
if (nextTags.length > 0) {
next.tags = nextTags
} else {
delete next.tags
}
return next
})
}
const clearAllTags = () => {
setFilter(prev => {
const next = { ...prev }
delete next.tags
return next
})
}
const filteredTags = React.useMemo(() => {
const q = search.toLowerCase()
return tagsCount
.filter(t =>
!q || t.tag.toLowerCase().includes(q)
)
.sort((a, b) => {
const aSelected = selectedTags.includes(a.tag)
const bSelected = selectedTags.includes(b.tag)
if (aSelected && !bSelected) return -1
if (!aSelected && bSelected) return 1
return b.count - a.count
})
}, [tagsCount, search, selectedTags])
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn(
'h-8 gap-1.5 px-3 rounded-none',
selectedTags.length > 0 &&
'bg-primary/10 border-primary text-primary'
)}
>
<Tag className="h-4 w-4" />
{t('tag.label')}
{selectedTags.length > 0 && (
<Badge
variant="secondary"
className="ml-1 h-5 px-1.5 text-xs"
>
{selectedTags.length}
</Badge>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-96 p-1"
>
<div className="p-1 pb-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('tag.search_placeholder')}
className="h-8 text-sm"
autoFocus
/>
</div>
<ScrollArea className="h-96 p-1">
{!search && selectedTags.length > 0 && (
<>
<div
onClick={clearAllTags}
className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-destructive hover:bg-destructive/10 transition-colors"
>
<div className="flex h-4 w-4 items-center justify-center">
<X className="h-3 w-3" />
</div>
<span className="flex-1 text-xs font-medium">
{t('tag.clear_all')}
</span>
<span className="text-[10px] opacity-60">({selectedTags.length})</span>
</div>
<div className="my-1 h-px bg-border" />
</>
)}
{isLoading ? (
<div className="space-y-2 p-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-4 rounded bg-muted animate-pulse"
/>
))}
</div>
) : filteredTags.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('tag.no_tags_found')}
</p>
) : (
filteredTags.map(({ tag, count }) => {
const checked = selectedTags.includes(tag)
const id = `tag-${tag}`
return (
<div
key={tag}
onClick={() => handleTagToggle(tag)}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
'hover:bg-accent transition-colors'
)}
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={() =>
handleTagToggle(tag)
}
onClick={(e) =>
e.stopPropagation()
}
/>
<Label
htmlFor={id}
className="flex-1 truncate text-xs cursor-pointer"
title={tag}
>
{tag}
</Label>
<Badge
variant="secondary"
className="h-5 px-1.5 text-xs"
>
{count}
</Badge>
</div>
)
})
)}
</ScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,200 @@
import React, { useState, useEffect, useRef } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Search, X, Clock, Trash2, Info } from "lucide-react"
import { cn } from "@/lib/utils"
import { useSearchContext } from "./context"
import { useTranslation } from "react-i18next"
const STORAGE_KEY = "mail_search_history"
const MAX_HISTORY = 20
export function TextSearchInput() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const [value, setValue] = useState(filter.text || "")
const [history, setHistory] = useState<string[]>([])
const [showHistory, setShowHistory] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
setHistory(JSON.parse(saved))
}
} catch (err) {
console.warn("Failed to load search history", err)
}
}, [])
useEffect(() => {
setValue(filter.text || "")
}, [filter.text])
const saveToHistory = (term: string) => {
if (!term.trim()) return
setHistory(prev => {
const trimmed = term.trim()
const withoutCurrent = prev.filter(item => item !== trimmed)
const newHistory = [trimmed, ...withoutCurrent].slice(0, MAX_HISTORY)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
} catch (err) {
console.warn("Failed to save search history", err)
}
return newHistory
})
}
const handleSearch = () => {
const trimmed = value.trim()
setFilter(prev => ({
...prev,
text: trimmed || undefined
}))
if (trimmed) {
saveToHistory(trimmed)
}
setShowHistory(false)
inputRef.current?.blur()
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault()
handleSearch()
}
}
const handleClear = () => {
setValue("")
setFilter(prev => {
const next = { ...prev }
delete next.text
return next
})
setShowHistory(false)
}
const handleSelectHistory = (term: string) => {
setValue(term)
setShowHistory(false)
// 如果需要点击历史立即搜索,可以在这里调用 handleSearch()
}
const handleClearHistory = () => {
setHistory([])
try {
localStorage.removeItem(STORAGE_KEY)
} catch (err) {
console.warn("Failed to clear search history", err)
}
setShowHistory(false)
}
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowHistory(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const isActive = !!filter.text?.trim()
return (
<div ref={containerRef} className="relative w-full max-w-[550px] min-w-[280px]">
<div className="flex flex-col gap-1.5">
<div className="relative flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={() => setShowHistory(true)}
onKeyDown={handleKeyDown}
placeholder={t('search_input.placeholder')}
className={cn(
"h-9 pl-9 pr-9 text-sm",
isActive && "border-primary/50 focus-visible:ring-primary/30"
)}
/>
{value && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<Button
size="sm"
className="h-9 px-5"
onClick={handleSearch}
disabled={!value.trim()}
>
{t('search_input.button')}
</Button>
</div>
{/* 搜索范围提示 */}
<div className="flex items-center gap-1 px-1 opacity-60">
<Info className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">
{t('search_input.hint')}
</span>
</div>
</div>
{showHistory && (
<div className="absolute top-10 left-0 w-full mt-1 bg-popover border rounded-md shadow-md z-50 max-h-[280px] overflow-auto">
<div className="py-1.5 px-3 text-xs text-muted-foreground font-medium border-b flex items-center justify-between sticky top-0 bg-popover z-10">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3" />
{t('search_input.recent_title')}
</div>
{history.length > 0 && (
<button
onClick={handleClearHistory}
className="text-xs text-destructive hover:text-destructive/80 flex items-center gap-1 hover:underline"
>
<Trash2 className="h-3 w-3" />
{t('search_input.clear_history')}
</button>
)}
</div>
{history.length > 0 ? (
history.map((term, idx) => (
<button
key={idx}
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex items-center gap-2"
onClick={() => handleSelectHistory(term)}
>
<Search className="h-3.5 w-3.5 text-muted-foreground" />
{term}
</button>
))
) : (
<div className="px-3 py-4 text-xs text-center text-muted-foreground">
{t('search_input.no_history')}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,205 @@
import * as React from 'react'
import { CalendarRange, ChevronDown, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { format } from 'date-fns'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useSearchContext } from './context'
import { DatePicker } from '@/components/date-picker'
const DAY = 86400000
export function TimePopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const [customDays, setCustomDays] = React.useState<string>('')
const since = filter.since
const before = filter.before
const toDate = (ts: number) => {
return format(ts, t('time.format'))
}
const label = (s?: number, b?: number) => {
if (!s && !b) return t('time.label')
if (s && b) return `${toDate(s)}${toDate(b)}`
if (s) return `${t('time.since')} ${toDate(s)}`
return `${t('time.before')} ${toDate(b!)}`
}
const setRange = (s?: number, b?: number) => {
setFilter(prev => {
const next = { ...prev }
s ? (next.since = s) : delete next.since
b ? (next.before = b) : delete next.before
return next
})
}
const setSince = (s?: number) => setRange(s, before)
const setBefore = (b?: number) => setRange(since, b)
const handleApplyRecent = () => {
const days = parseInt(customDays)
if (!isNaN(days) && days > 0) {
setRange(Date.now() - days * DAY, undefined)
}
}
const clear = () => {
setRange()
setCustomDays('')
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn(
'h-8 rounded-none px-3 gap-1.5 transition-colors',
(since || before) && 'bg-primary/10 text-primary hover:bg-primary/20'
)}
>
<CalendarRange className="h-4 w-4" />
{label(since, before)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[530px] p-4 space-y-6">
<Section title={t('time.recent_range')}>
<div className="space-y-4 w-full">
<div className="flex flex-wrap gap-2">
{[1, 7, 30].map(d => (
<Quick key={d} onClick={() => setRange(Date.now() - d * DAY, undefined)}>
{d === 1 ? t('time.last_day') : t('time.last_days', { count: d })}
</Quick>
))}
{[3, 6].map(m => (
<Quick key={m} onClick={() => setRange(Date.now() - m * 30 * DAY, undefined)}>
{t('time.last_months', { count: m })}
</Quick>
))}
</div>
<div className="flex items-center gap-2 pt-3 border-t border-border/50">
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">{t('time.recent_prefix')}</span>
<Input
type="number"
min={1}
placeholder="10"
className="h-8 w-20 text-xs"
value={customDays}
onChange={e => setCustomDays(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleApplyRecent()}
/>
<span className="text-xs text-muted-foreground shrink-0">{t('time.days_ago_to_now')}</span>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 ml-auto text-xs"
onClick={handleApplyRecent}
>
{t('time.apply')}
</Button>
</div>
</div>
</Section>
<Section title={t('time.historical')}>
<div className="flex flex-wrap gap-2 w-full">
{[1, 2, 3, 5, 10].map(y => (
<Quick
key={y}
onClick={() => setRange(undefined, Date.now() - y * 365 * DAY)}
className="border-orange-200 hover:border-orange-400 hover:text-orange-600"
>
{t('time.over_years_ago', {
count: y,
unit: y === 1 ? t('time.year') : t('time.years')
})}
</Quick>
))}
</div>
</Section>
<Section title={t('time.absolute_range')}>
<div className="flex gap-3 w-full">
<div className="flex-1 min-w-0 space-y-1.5">
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.since').toUpperCase()}</span>
<DatePicker
placeholder={t('time.start_date')}
selected={since ? new Date(since) : undefined}
onSelect={(date) => setSince(date?.getTime())}
/>
</div>
<div className="flex-1 min-w-0 space-y-1.5">
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.before').toUpperCase()}</span>
<DatePicker
placeholder={t('time.end_date')}
selected={before ? new Date(before) : undefined}
onSelect={(date) => setBefore(date?.getTime())}
/>
</div>
</div>
</Section>
{(since || before) && (
<div className="px-1 pb-2">
<Button
variant="ghost"
size="sm"
onClick={clear}
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X className="mr-2 h-3.5 w-3.5" />
{t('time.clear_filters')}
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="flex flex-col items-start w-full">
<div className="text-[11px] font-semibold mb-2.5 text-muted-foreground uppercase tracking-wider">
{title}
</div>
{children}
</div>
)
}
function Quick({
children,
onClick,
className
}: {
children: React.ReactNode;
onClick: () => void;
className?: string
}) {
return (
<Button
size="sm"
variant="outline"
className={cn(
"h-7 px-2.5 text-xs font-normal hover:bg-primary/5 hover:text-primary shrink-0",
className
)}
onClick={onClick}
>
{children}
</Button>
)
}

View File

@@ -91,7 +91,7 @@ export function APITokens() {
</Button>
</div>
<ScrollArea className="h-[40rem] w-full pr-4 -mr-4 py-1">
<ScrollArea className="h-[calc(100vh-16rem)] w-full pr-4 -mr-4 py-1">
<TokenCardList tokens={tokens} userId={user.id} />
</ScrollArea>
</>

View File

@@ -44,7 +44,7 @@ export function Profile() {
}
return (
<div className="w-full max-w-6xl ml-0 px-4">
<div className="w-full max-w-7xl ml-0 px-4">
<UserProfileForm user={user!} />
</div>
)

View File

@@ -132,7 +132,7 @@ export function PermissionsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl w-[90vw] overflow-hidden flex flex-col max-h-[90vh]">
<DialogContent className="max-w-4xl w-[90vw] overflow-hidden flex flex-col max-h-[90vh]">
<DialogHeader className="pb-4 border-b">
<div className="flex items-center gap-3">
<DialogTitle>{title}</DialogTitle>

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