mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d150c2982 | ||
|
|
9b49005522 | ||
|
|
1f4e9f7b06 | ||
|
|
d0e4cac229 | ||
|
|
30a8d856c1 | ||
|
|
7240be8c31 | ||
|
|
0d20a9676a | ||
|
|
48f8092b5a | ||
|
|
a64351d409 | ||
|
|
a6dd1d19ff | ||
|
|
f82b20e2fd | ||
|
|
9841038acb | ||
|
|
97c3db3bd2 | ||
|
|
82fb2a02bc | ||
|
|
c61977ce5c | ||
|
|
106a08fb7e | ||
|
|
3419506c2e | ||
|
|
3b040d0cd6 | ||
|
|
5d3c319a67 | ||
|
|
ad2a43aa35 | ||
|
|
884ae64fc5 | ||
|
|
254de35f0e | ||
|
|
bc3eba5bf7 | ||
|
|
4dc99b4a84 | ||
|
|
8ce8b9692d | ||
|
|
3fb761064d | ||
|
|
54a0a71c44 | ||
|
|
b490923e17 | ||
|
|
4ee44daf0d | ||
|
|
7edd7c2e35 | ||
|
|
0c46432150 | ||
|
|
d334a23ca7 | ||
|
|
1768c1a590 | ||
|
|
147f5b4f55 | ||
|
|
48312dc83e |
476
Cargo.lock
generated
476
Cargo.lock
generated
@@ -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.3.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"async-imap",
|
||||
@@ -435,6 +455,8 @@ dependencies = [
|
||||
"cacache",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codepage-strings",
|
||||
"compressed-rtf",
|
||||
"console",
|
||||
"dashmap",
|
||||
"dialoguer",
|
||||
@@ -451,6 +473,7 @@ dependencies = [
|
||||
"itoa",
|
||||
"lru 0.16.2",
|
||||
"mail-parser",
|
||||
"mail-send",
|
||||
"memmap2 0.9.9",
|
||||
"mimalloc",
|
||||
"mime_guess",
|
||||
@@ -460,6 +483,7 @@ dependencies = [
|
||||
"num_cpus",
|
||||
"oauth2",
|
||||
"openssl-sys",
|
||||
"outlook-pst",
|
||||
"poem",
|
||||
"poem-derive",
|
||||
"poem-openapi",
|
||||
@@ -624,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"
|
||||
@@ -736,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",
|
||||
@@ -747,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",
|
||||
@@ -784,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"
|
||||
@@ -838,6 +911,12 @@ 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"
|
||||
@@ -847,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",
|
||||
@@ -876,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"
|
||||
@@ -885,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"
|
||||
@@ -951,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]]
|
||||
@@ -1075,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"
|
||||
@@ -1085,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"
|
||||
@@ -1135,6 +1252,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1208,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"
|
||||
@@ -1294,6 +1424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"libz-rs-sys",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
@@ -1699,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"
|
||||
@@ -1706,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]]
|
||||
@@ -1719,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"
|
||||
@@ -2080,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"
|
||||
@@ -2211,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"
|
||||
@@ -2243,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"
|
||||
@@ -2330,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"
|
||||
@@ -2347,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"
|
||||
@@ -2373,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"
|
||||
@@ -2481,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"
|
||||
@@ -2690,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"
|
||||
@@ -2742,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"
|
||||
@@ -2780,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"
|
||||
@@ -2915,7 +3250,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"poem-derive",
|
||||
"quick-xml",
|
||||
"quick-xml 0.36.2",
|
||||
"regex",
|
||||
"rfc7239",
|
||||
"rust-embed",
|
||||
@@ -2965,7 +3300,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"poem",
|
||||
"poem-openapi-derive",
|
||||
"quick-xml",
|
||||
"quick-xml 0.36.2",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3039,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"
|
||||
@@ -3124,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"
|
||||
@@ -3919,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"
|
||||
@@ -4193,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"
|
||||
@@ -4524,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",
|
||||
@@ -4808,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",
|
||||
@@ -4934,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]]
|
||||
@@ -5764,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"
|
||||
@@ -5798,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"
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
@@ -23,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"
|
||||
@@ -41,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"] }
|
||||
@@ -71,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 = [
|
||||
@@ -115,6 +115,10 @@ 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"
|
||||
|
||||
37
README.md
37
README.md
@@ -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?
|
||||
|
||||
@@ -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 you’d like to support its development, you can consider making a small donation or helping spread the word.
|
||||
Financial support is optional but deeply appreciated — it helps me dedicate more time and resources to building new features and improving the overall experience.
|
||||
Bichon is an open-source email platform focused on privacy, local ownership, and long-term stability.
|
||||
|
||||
You can also support the project by sharing it with others, writing about your experience, or recommending it within relevant communities. Every bit of visibility helps more people benefit from the tool!
|
||||
The project is freely available and fully functional for everyone.
|
||||
|
||||
Some members of the community choose to support the project financially. This support helps sustain ongoing development and long-term maintenance, while keeping the project independent and user-driven.
|
||||
|
||||
Support is always optional. You can also contribute by sharing feedback, reporting issues, or recommending Bichon to others.
|
||||
|
||||
[](https://buymeacoffee.com/rustmailer)
|
||||
|
||||
|
||||
2
env/rustmailer.env → env/bichon.env
vendored
2
env/rustmailer.env → env/bichon.env
vendored
@@ -28,7 +28,7 @@ BICHON_ROOT_DIR=/data/bichon-data
|
||||
# Enable API access token validation
|
||||
BICHON_ENABLE_ACCESS_TOKEN=false
|
||||
|
||||
# IP address to bind the HTTP and gRPC servers to (default: 0.0.0.0)
|
||||
# IP address to bind the HTTP servers to (default: 0.0.0.0)
|
||||
BICHON_BIND_IP=
|
||||
|
||||
# Comma-separated list of allowed CORS origins (e.g. https://app.example.com)
|
||||
@@ -1,7 +1,5 @@
|
||||
use bichon::modules::cli::{
|
||||
auth::verify_user_and_get_account, eml::handle_eml_directory_import,
|
||||
mbox::handle_mbox_single_file_import, thunderbird::handle_thunderbird_import, BichonCli,
|
||||
BichonCtlConfig,
|
||||
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;
|
||||
@@ -77,6 +75,7 @@ async fn main() {
|
||||
"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)
|
||||
@@ -90,6 +89,7 @@ async fn main() {
|
||||
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!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
@@ -143,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>,
|
||||
|
||||
@@ -102,7 +102,13 @@ pub async fn run_import(
|
||||
target_folder: Option<String>,
|
||||
) {
|
||||
let client = Client::new();
|
||||
let mbox = MboxFile::from_file(mbox_path).unwrap();
|
||||
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;
|
||||
|
||||
@@ -180,7 +180,7 @@ mod tests {
|
||||
#[test]
|
||||
fn many_small_mails() {
|
||||
let mut data = Vec::new();
|
||||
for i in 0..1000 {
|
||||
for _ in 0..1000 {
|
||||
data.extend_from_slice(b"From a\nx\n");
|
||||
}
|
||||
let e = collect_entries(&data);
|
||||
@@ -203,8 +203,6 @@ mod tests {
|
||||
determine_folder(labels)
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ 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",
|
||||
|
||||
45
src/modules/cli/pst/encoding/mod.rs
Normal file
45
src/modules/cli/pst/encoding/mod.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use compressed_rtf::*;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
pub fn decode_subject(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => {
|
||||
let offset = match value.buffer().first() {
|
||||
Some(1) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let buffer: Vec<_> = value
|
||||
.buffer()
|
||||
.iter()
|
||||
.skip(offset)
|
||||
.map(|&b| u16::from(b))
|
||||
.collect();
|
||||
Some(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
PropertyValue::Unicode(value) => {
|
||||
let offset = match value.buffer().first() {
|
||||
Some(1) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
Some(String::from_utf16_lossy(&value.buffer()[offset..]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option<String> {
|
||||
match code_page {
|
||||
20127 => {
|
||||
let buffer: Vec<_> = buffer.iter().map(|&b| u16::from(b)).collect();
|
||||
Some(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
_ => {
|
||||
let coding = codepage_strings::Coding::new(code_page).ok()?;
|
||||
Some(coding.decode(buffer).ok()?.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_rtf_compressed(buffer: &[u8]) -> Option<String> {
|
||||
decompress_rtf(buffer).ok()
|
||||
}
|
||||
458
src/modules/cli/pst/mod.rs
Normal file
458
src/modules/cli/pst/mod.rs
Normal file
@@ -0,0 +1,458 @@
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::Input;
|
||||
use mail_send::mail_builder::headers::text::Text;
|
||||
use mail_send::mail_builder::MessageBuilder;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
use crate::base64_encode_url_safe;
|
||||
use crate::modules::cli::pst::encoding::decode_subject;
|
||||
use crate::modules::cli::sender::send_batch_request;
|
||||
use crate::modules::cli::BichonCtlConfig;
|
||||
use dialoguer::Confirm;
|
||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||
use outlook_pst::messaging::folder::Folder;
|
||||
use outlook_pst::messaging::message::{Message, MessageProperties};
|
||||
use outlook_pst::ndb::node_id::NodeId;
|
||||
use reqwest::Client;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
|
||||
mod encoding;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailMetadata {
|
||||
pub message_id: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<Vec<String>>,
|
||||
pub cc: Option<Vec<String>>,
|
||||
pub bcc: Option<Vec<String>>,
|
||||
pub html: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailAttachment {
|
||||
pub name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) {
|
||||
let path_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the path to your SINGLE .pst file")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if !p.exists() {
|
||||
return Err("The specified path does not exist.");
|
||||
}
|
||||
|
||||
if !p.is_file() {
|
||||
return Err("PST mode requires a SINGLE file, not a directory.");
|
||||
}
|
||||
let is_pst = p
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("pst"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_pst {
|
||||
return Err("The selected file must have a .pst extension.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let pst_path = std::path::PathBuf::from(path_str);
|
||||
|
||||
println!(
|
||||
"\n{} Ready to process PST file: {}",
|
||||
console::style("✔").green(),
|
||||
console::style(pst_path.display()).cyan()
|
||||
);
|
||||
|
||||
if let Ok(meta) = std::fs::metadata(&pst_path) {
|
||||
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
|
||||
println!(
|
||||
"{}",
|
||||
console::style(format!("PST File Size: {:.1} MB", size_mb)).dim()
|
||||
);
|
||||
}
|
||||
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt("Start importing emails from this PST?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
parse_pst(pst_path, config, account_id).await;
|
||||
} else {
|
||||
println!("{}", console::style("Operation cancelled by user.").red());
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) {
|
||||
let client = Client::new();
|
||||
|
||||
let pst_store = match outlook_pst::open_store(&pst_path) {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Failed to open PST file: {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Could not find IPM_SUBTREE (Mailbox Root): {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
|
||||
Ok(folder) => folder,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Failed to open the root mailbox folder: {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
process_folder_recursively(&client, &ipm_subtree_folder, "", config, account_id).await;
|
||||
}
|
||||
|
||||
fn process_folder_recursively<'a>(
|
||||
client: &'a Client,
|
||||
folder: &'a Rc<dyn Folder>,
|
||||
parent_path: &'a str,
|
||||
config: &'a BichonCtlConfig,
|
||||
account_id: u64,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
|
||||
Box::pin(async move {
|
||||
let folder_name = folder
|
||||
.properties()
|
||||
.display_name()
|
||||
.unwrap_or_else(|_| "Unknown".to_string());
|
||||
|
||||
let current_path = if parent_path.is_empty() {
|
||||
folder_name
|
||||
} else {
|
||||
format!("{}/{}", parent_path, folder_name)
|
||||
};
|
||||
|
||||
println!(
|
||||
"{} {}",
|
||||
console::style("📁 Folder:").dim(),
|
||||
console::style(¤t_path).cyan()
|
||||
);
|
||||
|
||||
let mut emls_batch = Vec::new();
|
||||
|
||||
if let Some(contents_table) = folder.contents_table() {
|
||||
for row in contents_table.rows_matrix() {
|
||||
let store = folder.store().clone();
|
||||
|
||||
let entry_id = match store
|
||||
.properties()
|
||||
.make_entry_id(NodeId::from(u32::from(row.id())))
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {} Skip row {}: {:?}",
|
||||
console::style("⚠").yellow(),
|
||||
row.unique(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match store.open_message(&entry_id, None) {
|
||||
Ok(message) => match build_eml_base64(message) {
|
||||
Some(base64_eml) => emls_batch.push(base64_eml),
|
||||
None => {}
|
||||
},
|
||||
Err(e) => eprintln!(" {} Open error: {:?}", console::style("⚠").yellow(), e),
|
||||
}
|
||||
|
||||
if emls_batch.len() >= 50 {
|
||||
let batch = emls_batch.clone();
|
||||
emls_batch.clear();
|
||||
send_to_bichon(client, config, account_id, ¤t_path, batch).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !emls_batch.is_empty() {
|
||||
send_to_bichon(client, config, account_id, ¤t_path, emls_batch).await;
|
||||
}
|
||||
|
||||
if let Some(hierarchy_table) = folder.hierarchy_table() {
|
||||
for row in hierarchy_table.rows_matrix() {
|
||||
let node = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
|
||||
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
|
||||
process_folder_recursively(
|
||||
client,
|
||||
&sub_folder,
|
||||
¤t_path,
|
||||
config,
|
||||
account_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
|
||||
let properties = message.properties();
|
||||
|
||||
let mut builder = MessageBuilder::new();
|
||||
if let Some(sub) = extract_subject(properties) {
|
||||
builder = builder.subject(sub);
|
||||
}
|
||||
if let Some(mid) = extract_string_property(properties, 0x1035) {
|
||||
builder = builder.message_id(mid);
|
||||
}
|
||||
if let Some(irt) = extract_string_property(properties, 0x1042) {
|
||||
builder = builder.in_reply_to(irt);
|
||||
}
|
||||
|
||||
if let Some(refs) = extract_string_property(properties, 0x1039) {
|
||||
builder = builder.header("References", Text::new(refs));
|
||||
}
|
||||
|
||||
if let Some(cid_val) = properties.get(0x3013) {
|
||||
if let PropertyValue::Binary(bin) = cid_val {
|
||||
builder = builder.header(
|
||||
"X-Bichon-Conversation-ID",
|
||||
Text::new(hex::encode(bin.buffer())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let from = extract_string_property(properties, 0x5D01)
|
||||
.or_else(|| extract_string_property(properties, 0x5D02))
|
||||
.or_else(|| extract_string_property(properties, 0x0C1F));
|
||||
|
||||
if let Some(f) = from {
|
||||
builder = builder.from(f);
|
||||
}
|
||||
|
||||
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
|
||||
let dt = filetime_to_datetime(filetime).timestamp();
|
||||
builder = builder.date(dt);
|
||||
}
|
||||
|
||||
let (to, cc, bcc) = extract_recipients_list(&message);
|
||||
if !to.is_empty() {
|
||||
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !cc.is_empty() {
|
||||
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !bcc.is_empty() {
|
||||
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
if let Some(html) = extract_html(properties) {
|
||||
builder = builder.html_body(html);
|
||||
}
|
||||
|
||||
if let Some(text) = extract_text(properties) {
|
||||
builder = builder.text_body(text);
|
||||
}
|
||||
|
||||
if let Some(attachment_table) = message.attachment_table() {
|
||||
for row in attachment_table.rows_matrix() {
|
||||
let node_id = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
|
||||
let att_props = attachment.properties();
|
||||
let name = extract_attachment_string_property(att_props, 0x3707);
|
||||
let mime = extract_attachment_string_property(att_props, 0x370E)
|
||||
.unwrap_or_else(|| "application/octet-stream".into());
|
||||
let cid = extract_attachment_string_property(att_props, 0x3712);
|
||||
let is_inline = att_props
|
||||
.get(0x3714)
|
||||
.and_then(|val| {
|
||||
if let PropertyValue::Integer32(f) = val {
|
||||
Some(f)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|flag| (flag & 0x4) != 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
|
||||
let data = bin.buffer().to_vec();
|
||||
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
|
||||
|
||||
if is_inline && cid.is_some() {
|
||||
let content_id = cid.unwrap();
|
||||
builder = builder.inline(mime, content_id, data);
|
||||
} else {
|
||||
builder = builder.attachment(mime, file_name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match builder.write_to_vec() {
|
||||
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to generate EML: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
|
||||
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
|
||||
let nsecs = (filetime % 10_000_000) * 100;
|
||||
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
|
||||
}
|
||||
|
||||
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let mut to = Vec::new();
|
||||
let mut cc = Vec::new();
|
||||
let mut bcc = Vec::new();
|
||||
|
||||
let recipient_table = message.recipient_table();
|
||||
let context = recipient_table.context();
|
||||
|
||||
for row in recipient_table.rows_matrix() {
|
||||
if let Ok(cols) = row.columns(context) {
|
||||
let mut r_type = 0;
|
||||
let mut email = String::new();
|
||||
|
||||
for (col, val) in context.columns().iter().zip(cols) {
|
||||
let prop_val = val
|
||||
.as_ref()
|
||||
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
|
||||
match col.prop_id() {
|
||||
0x0C15 => {
|
||||
if let Some(PropertyValue::Integer32(t)) = prop_val {
|
||||
r_type = t;
|
||||
}
|
||||
}
|
||||
0x39FE | 0x3003 => {
|
||||
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
|
||||
email = s;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !email.is_empty() {
|
||||
match r_type {
|
||||
1 => to.push(email),
|
||||
2 => cc.push(email),
|
||||
3 => bcc.push(email),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(to, cc, bcc)
|
||||
}
|
||||
|
||||
async fn send_to_bichon(
|
||||
client: &Client,
|
||||
config: &BichonCtlConfig,
|
||||
account_id: u64,
|
||||
folder_path: &str,
|
||||
emls: Vec<String>,
|
||||
) {
|
||||
send_batch_request(client, config, account_id, folder_path, emls).await;
|
||||
}
|
||||
|
||||
fn extract_subject(props: &MessageProperties) -> Option<String> {
|
||||
props.get(0x0037).and_then(|val| decode_subject(val))
|
||||
}
|
||||
|
||||
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_attachment_string_property(
|
||||
properties: &AttachmentProperties,
|
||||
prop_id: u16,
|
||||
) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_string(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_text(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1000).and_then(extract_string).or_else(|| {
|
||||
properties.get(0x1009).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_html(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1013).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => {
|
||||
let code_page = properties
|
||||
.get(0x3FDE)
|
||||
.and_then(|v| {
|
||||
if let PropertyValue::Integer32(cpid) = v {
|
||||
Some(*cpid as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(65001);
|
||||
encoding::decode_html_body(value.buffer(), code_page)
|
||||
}
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
|
||||
for &prop_id in prop_ids {
|
||||
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
|
||||
return Some(*value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -97,7 +97,6 @@ pub async fn handle_thunderbird_import(
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
// 3. 执行导入
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -100,7 +100,6 @@ impl ImportEmls {
|
||||
|
||||
let total = request.emls.len();
|
||||
for (index, eml_base64) in request.emls.into_iter().enumerate() {
|
||||
// 1. Decode Base64
|
||||
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::cache::imap::mailbox::MailBox;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
@@ -155,9 +157,9 @@ impl Envelope {
|
||||
let id = create_hash(account_id, &message_id);
|
||||
let full_text = extract_string_field(doc, fields.f_text)?;
|
||||
|
||||
// Take up to the first 120 characters as a preview;
|
||||
let preview = if full_text.chars().count() > 120 {
|
||||
full_text.chars().take(120).collect::<String>() + "..."
|
||||
// Take up to the first 500 characters as a preview;
|
||||
let preview = if full_text.chars().count() > 500 {
|
||||
full_text.chars().take(500).collect::<String>() + "..."
|
||||
} else {
|
||||
full_text
|
||||
};
|
||||
@@ -204,3 +206,28 @@ impl Envelope {
|
||||
Ok(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
|
||||
let fields = SchemaTools::envelope_fields();
|
||||
let mut all_contacts = HashSet::new();
|
||||
|
||||
if let Ok(from_val) = extract_string_field(doc, fields.f_from) {
|
||||
if !from_val.is_empty() {
|
||||
all_contacts.insert(from_val);
|
||||
}
|
||||
}
|
||||
|
||||
let multi_fields = [fields.f_to, fields.f_cc, fields.f_bcc];
|
||||
|
||||
for field in multi_fields {
|
||||
if let Ok(vals) = extract_vec_string_field(doc, field) {
|
||||
for v in vals {
|
||||
if !v.is_empty() {
|
||||
all_contacts.insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_contacts)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::modules::message::tags::TagCount;
|
||||
use crate::modules::{
|
||||
indexer::envelope::extract_contacts,
|
||||
message::{search::SortBy, tags::TagCount},
|
||||
};
|
||||
use crate::{
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
@@ -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,
|
||||
@@ -306,11 +312,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 +331,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 +353,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 +530,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 +673,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 +706,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 +921,6 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn top_10_largest_emails(
|
||||
&self,
|
||||
accounts: &Option<HashSet<u64>>,
|
||||
|
||||
10
src/modules/message/contacts.rs
Normal file
10
src/modules/message/contacts.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct Contact {
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,16 +46,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
|
||||
@@ -173,7 +173,44 @@ pub struct Settings {
|
||||
})
|
||||
)]
|
||||
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("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));
|
||||
}
|
||||
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("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));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_data_dir: Option<String>,
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::context::Initialize;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::{
|
||||
@@ -35,7 +34,6 @@ const LOG_DIR: &str = "logs";
|
||||
const TLS_CERT: &str = "cert.pem";
|
||||
const TLS_KEY: &str = "key.pem";
|
||||
|
||||
|
||||
pub static DATA_DIR_MANAGER: LazyLock<DataDirManager> =
|
||||
LazyLock::new(|| DataDirManager::new(PathBuf::from(&SETTINGS.bichon_root_dir)));
|
||||
|
||||
@@ -49,7 +47,7 @@ pub struct DataDirManager {
|
||||
pub tls_key: PathBuf,
|
||||
pub envelope_dir: PathBuf,
|
||||
pub eml_dir: PathBuf,
|
||||
pub log_dir: PathBuf
|
||||
pub log_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Initialize for DataDirManager {
|
||||
@@ -66,6 +64,18 @@ impl Initialize for DataDirManager {
|
||||
|
||||
impl DataDirManager {
|
||||
pub fn new(root_dir: PathBuf) -> Self {
|
||||
let envelope_dir = if let Some(ref index_dir) = SETTINGS.bichon_index_dir {
|
||||
PathBuf::from(index_dir)
|
||||
} else {
|
||||
root_dir.join(ENVELOPE_DIR)
|
||||
};
|
||||
|
||||
let eml_dir = if let Some(ref data_dir) = SETTINGS.bichon_data_dir {
|
||||
PathBuf::from(data_dir)
|
||||
} else {
|
||||
root_dir.join(EML_DIR)
|
||||
};
|
||||
|
||||
Self {
|
||||
root_dir: root_dir.clone(),
|
||||
meta_db: root_dir.join(META_FILE),
|
||||
@@ -73,9 +83,9 @@ impl DataDirManager {
|
||||
tls_key: root_dir.join(TLS_KEY),
|
||||
tls_cert: root_dir.join(TLS_CERT),
|
||||
log_dir: root_dir.join(LOG_DIR),
|
||||
envelope_dir: root_dir.join(ENVELOPE_DIR),
|
||||
envelope_dir,
|
||||
temp_dir: root_dir.join(TMP_DIR),
|
||||
eml_dir: root_dir.join(EML_DIR),
|
||||
eml_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||
"@react-spring/web": "^10.0.3",
|
||||
|
||||
6
web/pnpm-lock.yaml
generated
6
web/pnpm-lock.yaml
generated
@@ -86,6 +86,12 @@ importers:
|
||||
'@radix-ui/react-toast':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.1.10
|
||||
version: 1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-toggle-group':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
interface ScrollAreaProps
|
||||
extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
orientation?: 'horizontal' | 'vertical' | 'both'
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
@@ -24,7 +24,12 @@ const ScrollArea = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar orientation={orientation} />
|
||||
{orientation === "both" ? (
|
||||
<>
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</>
|
||||
) : <ScrollBar orientation={orientation} />}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
@@ -40,9 +45,9 @@ const ScrollBar = React.forwardRef<
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,13 +5,13 @@ const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className='relative w-full overflow-auto'>
|
||||
// <div className='relative w-full overflow-auto'>
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
// </div>
|
||||
))
|
||||
Table.displayName = 'Table'
|
||||
|
||||
@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
<thead ref={ref} className={cn('[&_th]:sticky [&_th]:top-0 [&_th]:bg-background [&_th]:z-20', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
|
||||
61
web/src/components/ui/toggle-group.tsx
Normal file
61
web/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
43
web/src/components/ui/toggle.tsx
Normal file
43
web/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -224,6 +224,7 @@ interface VirtualizedSelectProps {
|
||||
defaultValue?: string | string[];
|
||||
noItemsComponent?: React.ReactNode;
|
||||
multiple?: boolean;
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
export function VirtualizedSelect({
|
||||
@@ -232,6 +233,7 @@ export function VirtualizedSelect({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
size = 'default',
|
||||
isLoading,
|
||||
disabled = false,
|
||||
placeholder = 'Search items...',
|
||||
@@ -273,7 +275,7 @@ export function VirtualizedSelect({
|
||||
.filter(Boolean);
|
||||
|
||||
if (selectedLabels.length === 0) return placeholder;
|
||||
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
|
||||
return selectedLabels.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -281,13 +283,14 @@ export function VirtualizedSelect({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size={size}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('justify-between', className)}
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<span className='truncate'>{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -20,7 +20,7 @@ import React from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -52,9 +52,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useRoles } from '@/hooks/use-roles'
|
||||
import { useMinimalUsers } from '@/hooks/use-minimal-users'
|
||||
import { access_assign, AccountModel } from '@/api/account/api'
|
||||
import { list_account_roles, list_minimal_users, MinimalUser, UserRole } from '@/api/users/api'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccountModel
|
||||
@@ -71,12 +70,23 @@ export function AccountAccessAssignmentDialog({
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { accountRoles, isLoading: isLoadingRoles } = useRoles()
|
||||
const { users, isLoading: isLoadingUsers } = useMinimalUsers()
|
||||
const { data: roles, isLoading: isLoadingRoles } = useQuery<UserRole[]>({
|
||||
queryKey: ['account-role-list'],
|
||||
queryFn: list_account_roles,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
|
||||
const { data: users, isLoading: isLoadingUsers } = useQuery<MinimalUser[]>({
|
||||
queryKey: ['minimal-user-list'],
|
||||
queryFn: list_minimal_users,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const [keyword, setKeyword] = React.useState('')
|
||||
|
||||
// 1. 定义校验 Schema (集成国际化错误提示)
|
||||
const assignmentSchema = z.object({
|
||||
account_ids: z.array(z.number()),
|
||||
user_ids: z.array(z.number()).min(1, {
|
||||
@@ -101,7 +111,7 @@ export function AccountAccessAssignmentDialog({
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
if (!keyword.trim()) return users
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
return users.filter(
|
||||
return users!.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(lowerKeyword) ||
|
||||
user.email.toLowerCase().includes(lowerKeyword)
|
||||
@@ -170,7 +180,7 @@ export function AccountAccessAssignmentDialog({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{accountRoles.map((role) => (
|
||||
{roles && roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
@@ -206,12 +216,12 @@ export function AccountAccessAssignmentDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-1">
|
||||
{filteredUsers.length === 0 ? (
|
||||
{filteredUsers && (filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
{t('accounts.access_control.user_empty')}
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
filteredUsers!.map((user) => (
|
||||
<FormField
|
||||
key={user.id}
|
||||
control={form.control}
|
||||
@@ -242,7 +252,7 @@ export function AccountAccessAssignmentDialog({
|
||||
)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -358,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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -340,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}`);
|
||||
@@ -389,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) => {
|
||||
|
||||
@@ -32,7 +32,6 @@ export default function Mailboxes() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ===== Top Heading ===== */}
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<Mail
|
||||
|
||||
11
web/src/features/search/account-mailbox-filter.tsx
Normal file
11
web/src/features/search/account-mailbox-filter.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AccountPopover } from './account-popover'
|
||||
import { MailboxPopover } from './mailbox-popover'
|
||||
|
||||
export function AccountMailboxFilter() {
|
||||
return (
|
||||
<>
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
</>
|
||||
)
|
||||
}
|
||||
185
web/src/features/search/account-popover.tsx
Normal file
185
web/src/features/search/account-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
web/src/features/search/attachment-filter.tsx
Normal file
53
web/src/features/search/attachment-filter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
217
web/src/features/search/contact-popover.tsx
Normal file
217
web/src/features/search/contact-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
38
web/src/features/search/filter-reset.tsx
Normal file
38
web/src/features/search/filter-reset.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,35 @@ 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">
|
||||
{/* <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>
|
||||
</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 +115,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 +155,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 +164,4 @@ export default function Search() {
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
333
web/src/features/search/mail-list-table.tsx
Executable file
333
web/src/features/search/mail-list-table.tsx
Executable 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 />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } 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,
|
||||
@@ -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">
|
||||
@@ -257,7 +284,6 @@ export function MailMessageView({
|
||||
</>
|
||||
)}
|
||||
{showAttachments && <Separator className="my-2" />}
|
||||
{/* Attachments */}
|
||||
{showAttachments && (
|
||||
<div className="mb-2">
|
||||
{loading ? (
|
||||
@@ -265,13 +291,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 +330,7 @@ export function MailMessageView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs italic">
|
||||
@@ -304,7 +344,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">
|
||||
|
||||
263
web/src/features/search/mailbox-popover.tsx
Normal file
263
web/src/features/search/mailbox-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
194
web/src/features/search/more-filters-popover.tsx
Normal file
194
web/src/features/search/more-filters-popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
83
web/src/features/search/table/data-table-column-header.tsx
Executable file
83
web/src/features/search/table/data-table-column-header.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
121
web/src/features/search/table/data-table-row-actions.tsx
Executable file
121
web/src/features/search/table/data-table-row-actions.tsx
Executable file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// 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 } from 'lucide-react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { useSearchContext } from '../context'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<EmailEnvelope>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentEnvelope, 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)
|
||||
setOpen("restore")
|
||||
}}
|
||||
>
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
<DropdownMenuShortcut>
|
||||
<TagIcon 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
168
web/src/features/search/table/table.tsx
Executable file
168
web/src/features/search/table/table.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
41
web/src/features/search/table/toolbar.tsx
Normal file
41
web/src/features/search/table/toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
web/src/features/search/table/view-options.tsx
Normal file
80
web/src/features/search/table/view-options.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
193
web/src/features/search/tag-filter-popover.tsx
Normal file
193
web/src/features/search/tag-filter-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
web/src/features/search/text-search-input.tsx
Normal file
200
web/src/features/search/text-search-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
web/src/features/search/time-popover.tsx
Normal file
205
web/src/features/search/time-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -273,7 +273,7 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
|
||||
})}
|
||||
</h2>
|
||||
|
||||
<ScrollArea className="h-[32rem] pr-4">
|
||||
<ScrollArea className="h-[calc(100vh-16rem)] pr-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{accessibleAccountIds.map((accountId) => {
|
||||
const email = getEmailById(accountId)
|
||||
|
||||
@@ -71,7 +71,7 @@ const accountAccessEntry = (t: any) => z.object({
|
||||
const baseUserSchema = (t: any) => ({
|
||||
username: z.string()
|
||||
.min(1, t('users.actions.schema.username_required'))
|
||||
.min(5, t('users.actions.schema.username_min'))
|
||||
.min(3, t('users.actions.schema.username_min'))
|
||||
.max(32, t('users.actions.schema.username_max')),
|
||||
email: z.string()
|
||||
.min(1, t('users.actions.schema.email_required'))
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { get_top_tags } from '@/api/search/api';
|
||||
import { get_tags } from '@/api/search/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useAvailableTags(): UseAvailableTagsResult {
|
||||
refetch,
|
||||
} = useQuery<TagCount[]>({
|
||||
queryKey: ['all-tags'],
|
||||
queryFn: get_top_tags,
|
||||
queryFn: get_tags,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
26
web/src/hooks/use-contacts.ts
Normal file
26
web/src/hooks/use-contacts.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { get_contacts } from '@/api/search/api';
|
||||
|
||||
export const useContacts = (searchTerm: string = "") => {
|
||||
const { data: allContacts = [], isLoading, isError } = useQuery({
|
||||
queryKey: ['contacts', 'all'],
|
||||
queryFn: get_contacts,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchTerm) return allContacts;
|
||||
const lower = searchTerm.toLowerCase();
|
||||
return allContacts.filter(email =>
|
||||
email.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [allContacts, searchTerm]);
|
||||
|
||||
return {
|
||||
contacts: filtered,
|
||||
isLoading,
|
||||
isError
|
||||
};
|
||||
};
|
||||
@@ -1,51 +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 { list_minimal_users, MinimalUser } from '@/api/users/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export function useMinimalUsers() {
|
||||
const query = useQuery<MinimalUser[]>({
|
||||
queryKey: ['minimal-user-list'],
|
||||
queryFn: list_minimal_users,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const users = query.data ?? []
|
||||
const userMap = users.reduce((map, user) => {
|
||||
map[user.id] = user
|
||||
return map
|
||||
}, {} as Record<number, MinimalUser>)
|
||||
|
||||
|
||||
const getUsername = (id: number) => userMap[id]?.username ?? ''
|
||||
const getEmail = (id: number) => userMap[id]?.email ?? ''
|
||||
const getUser = (id: number) => userMap[id] ?? null
|
||||
const hasUser = (id: number) => !!userMap[id]
|
||||
|
||||
return {
|
||||
...query,
|
||||
users,
|
||||
userMap,
|
||||
getUsername,
|
||||
getEmail,
|
||||
getUser,
|
||||
hasUser,
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,23 @@
|
||||
import { EmailEnvelope, PaginatedResponse } from '@/api';
|
||||
import { search_messages } from '@/api/search/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
||||
|
||||
export function useSearchMessages() {
|
||||
// const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<Record<string, any>>({});
|
||||
const [filter, _setFilter] = useState<Record<string, any>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(30);
|
||||
const [sortBy, setSortBy] = useState<"DATE" | "SIZE">("DATE");
|
||||
const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc");
|
||||
|
||||
const setFilter = React.useCallback((val: any) => {
|
||||
_setFilter(val);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const onSubmit = (cleaned: Record<string, any>) => {
|
||||
if ('has_attachment' in cleaned && cleaned.has_attachment === false) {
|
||||
@@ -49,7 +57,6 @@ export function useSearchMessages() {
|
||||
|
||||
const reset = () => {
|
||||
setFilter({});
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -59,12 +66,14 @@ export function useSearchMessages() {
|
||||
error,
|
||||
isFetching,
|
||||
} = useQuery<PaginatedResponse<EmailEnvelope>>({
|
||||
queryKey: ['search-messages', filter, page, pageSize],
|
||||
queryKey: ['search-messages', filter, page, pageSize, sortBy, sortOrder],
|
||||
queryFn: () =>
|
||||
search_messages({
|
||||
filter: filter,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort_by: sortBy,
|
||||
desc: sortOrder === "desc"
|
||||
}),
|
||||
staleTime: 1000,
|
||||
retry: false,
|
||||
@@ -76,6 +85,10 @@ export function useSearchMessages() {
|
||||
totalPages: data?.total_pages ?? 1,
|
||||
pageSize: data?.page_size ?? pageSize,
|
||||
setPageSize,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isLoading,
|
||||
isError,
|
||||
error: error as Error | null,
|
||||
@@ -84,6 +97,7 @@ export function useSearchMessages() {
|
||||
setPage,
|
||||
onSubmit,
|
||||
reset,
|
||||
filter
|
||||
filter,
|
||||
setFilter
|
||||
};
|
||||
}
|
||||
@@ -5,78 +5,76 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 271 54% 99%;
|
||||
--foreground: 271 56% 5%;
|
||||
--muted: 91 11% 91%;
|
||||
--muted-foreground: 91 13% 31%;
|
||||
--popover: 271 54% 99%;
|
||||
--popover-foreground: 271 56% 5%;
|
||||
--card: 271 54% 98%;
|
||||
--card-foreground: 271 56% 4%;
|
||||
--border: 271 14% 89%;
|
||||
--input: 271 14% 89%;
|
||||
--background: 271 8% 98%;
|
||||
--foreground: 271 56% 6%;
|
||||
--muted: 271 8% 92%;
|
||||
--muted-foreground: 271 10% 35%;
|
||||
--popover: 271 8% 98%;
|
||||
--popover-foreground: 271 56% 6%;
|
||||
--card: 271 8% 97%;
|
||||
--card-foreground: 271 56% 5%;
|
||||
--border: 271 6% 88%;
|
||||
--input: 271 6% 88%;
|
||||
--primary: 271 60% 66%;
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--secondary: 91 60% 66%;
|
||||
--secondary-foreground: 91 60% 6%;
|
||||
--accent: 91 60% 66%;
|
||||
--accent-foreground: 91 60% 6%;
|
||||
--destructive: 17 89% 44%;
|
||||
--secondary: 91 55% 58%;
|
||||
--secondary-foreground: 91 50% 10%;
|
||||
--accent: 91 55% 58%;
|
||||
--accent-foreground: 91 50% 10%;
|
||||
--destructive: 17 85% 48%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--ring: 271 60% 66%;
|
||||
--chart-1: 271 60% 66%;
|
||||
--chart-2: 91 60% 66%;
|
||||
--chart-3: 91 60% 66%;
|
||||
--chart-4: 91 60% 69%;
|
||||
--chart-2: 91 55% 58%;
|
||||
--chart-3: 91 55% 58%;
|
||||
--chart-4: 91 55% 62%;
|
||||
--chart-5: 271 63% 66%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background: 271 54% 99%;
|
||||
--sidebar-foreground: 271 56% 5%;
|
||||
--sidebar-background: 271 8% 98%;
|
||||
--sidebar-foreground: 271 56% 6%;
|
||||
--sidebar-primary: 271 60% 66%;
|
||||
--sidebar-primary-foreground: 0 0% 0%;
|
||||
--sidebar-accent: 91 60% 66%;
|
||||
--sidebar-accent-foreground: 91 60% 6%;
|
||||
--sidebar-border: 271 14% 89%;
|
||||
--sidebar-accent: 91 55% 58%;
|
||||
--sidebar-accent-foreground: 91 50% 10%;
|
||||
--sidebar-border: 271 6% 88%;
|
||||
--sidebar-ring: 271 60% 66%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 271 46% 3%;
|
||||
--foreground: 271 10% 99%;
|
||||
--muted: 91 11% 9%;
|
||||
--muted-foreground: 91 13% 69%;
|
||||
--popover: 271 46% 3%;
|
||||
--popover-foreground: 271 10% 99%;
|
||||
--card: 271 46% 4%;
|
||||
--background: 271 20% 10%;
|
||||
--foreground: 271 10% 96%;
|
||||
--muted: 271 10% 14%;
|
||||
--muted-foreground: 271 10% 65%;
|
||||
--popover: 271 20% 10%;
|
||||
--popover-foreground: 271 10% 96%;
|
||||
--card: 271 20% 13%;
|
||||
--card-foreground: 0 0% 100%;
|
||||
--border: 271 14% 14%;
|
||||
--input: 271 14% 14%;
|
||||
--primary: 271 60% 66%;
|
||||
--border: 271 12% 22%;
|
||||
--input: 271 12% 22%;
|
||||
--primary: 271 50% 66%;
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--secondary: 91 60% 66%;
|
||||
--secondary-foreground: 91 60% 6%;
|
||||
--accent: 91 60% 66%;
|
||||
--accent-foreground: 91 60% 6%;
|
||||
--destructive: 17 89% 52%;
|
||||
--secondary: 91 45% 58%;
|
||||
--secondary-foreground: 91 40% 10%;
|
||||
--accent: 91 45% 58%;
|
||||
--accent-foreground: 91 40% 10%;
|
||||
--destructive: 17 85% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--ring: 271 60% 66%;
|
||||
--chart-1: 271 60% 66%;
|
||||
--chart-2: 91 60% 66%;
|
||||
--chart-3: 91 60% 66%;
|
||||
--chart-4: 91 60% 69%;
|
||||
--chart-5: 271 63% 66%;
|
||||
--ring: 271 50% 66%;
|
||||
--chart-1: 271 50% 66%;
|
||||
--chart-2: 91 45% 58%;
|
||||
--chart-3: 91 45% 58%;
|
||||
--chart-4: 91 45% 62%;
|
||||
--chart-5: 271 55% 66%;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background: 271 46% 3%;
|
||||
--sidebar-foreground: 271 10% 99%;
|
||||
--sidebar-primary: 271 60% 66%;
|
||||
--sidebar-background: 271 20% 10%;
|
||||
--sidebar-foreground: 271 10% 96%;
|
||||
--sidebar-primary: 271 50% 66%;
|
||||
--sidebar-primary-foreground: 0 0% 0%;
|
||||
--sidebar-accent: 91 60% 66%;
|
||||
--sidebar-accent-foreground: 91 60% 6%;
|
||||
--sidebar-border: 271 14% 14%;
|
||||
--sidebar-ring: 271 60% 66%;
|
||||
--sidebar-accent: 91 45% 58%;
|
||||
--sidebar-accent-foreground: 91 40% 10%;
|
||||
--sidebar-border: 271 12% 22%;
|
||||
--sidebar-ring: 271 50% 66%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "الكل",
|
||||
"tiny": "صغير جدًا (<15 كيلوبايت)",
|
||||
"small": "صغير (<2 ميغابايت)",
|
||||
"medium": "متوسط (<20 ميغابايت)",
|
||||
"large": "كبير (≥20 ميغابايت)",
|
||||
"medium": "متوسط (2 - 10 ميغابايت)",
|
||||
"large": "كبير (10 - 20 ميغابايت)",
|
||||
"huge": "ضخم (≥20 MB)",
|
||||
"sort": "فرز",
|
||||
"sizeDescription": "يشير الحجم إلى الحجم الإجمالي للبريد الإلكتروني، بما في ذلك المرفقات.",
|
||||
"title": "بحث",
|
||||
"searching": "جارٍ البحث، يرجى الانتظار...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "يرجى اختيار حساب",
|
||||
"role_required": "يرجى اختيار دور",
|
||||
"username_required": "اسم المستخدم مطلوب",
|
||||
"username_min": "5 أحرف على الأقل",
|
||||
"username_min": "3 أحرف على الأقل",
|
||||
"username_max": "32 حرفاً كحد أقصى",
|
||||
"email_required": "البريد الإلكتروني مطلوب",
|
||||
"email_invalid": "يرجى إدخال بريد إلكتروني صالح",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "تمت استعادة الرسائل المختارة إلى خادم IMAP بنجاح.",
|
||||
"failed": "فشلت استعادة الرسائل",
|
||||
"failedTitle": "فشل الاستعادة"
|
||||
},
|
||||
"time": {
|
||||
"label": "الوقت",
|
||||
"since": "منذ",
|
||||
"before": "قبل",
|
||||
"recent_range": "النطاق الأخير (منذ...)",
|
||||
"historical": "النطاق السابق (قبل...)",
|
||||
"absolute_range": "نطاق تاريخ محدد",
|
||||
"last_day": "آخر يوم واحد",
|
||||
"last_days": "آخر {{count}} أيام",
|
||||
"last_months": "آخر {{count}} أشهر",
|
||||
"over_years_ago": "منذ {{count}} {{unit}}",
|
||||
"year": "سنة",
|
||||
"years": "سنوات",
|
||||
"recent_prefix": "الأخيرة:",
|
||||
"days_ago_to_now": "منذ أيام حتى الآن",
|
||||
"apply": "تطبيق",
|
||||
"start_date": "تاريخ البدء",
|
||||
"end_date": "تاريخ الانتهاء",
|
||||
"clear_filters": "مسح فلترة الوقت",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "الوسوم",
|
||||
"search_placeholder": "بحث عن الوسوم...",
|
||||
"clear_all": "مسح جميع الوسوم",
|
||||
"no_tags_found": "لم يتم العثور على وسوم"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "حسابات البريد",
|
||||
"search_placeholder": "بحث عن حسابات البريد...",
|
||||
"clear_accounts": "مسح الحسابات المحددة",
|
||||
"no_accounts_found": "لم يتم العثور على حسابات"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "مجلدات البريد",
|
||||
"search_placeholder": "بحث في المجلدات",
|
||||
"clear_mailboxes": "مسح المجلدات المحددة",
|
||||
"select_account_first": "يرجى اختيار حساب بريد أولاً",
|
||||
"no_mailbox_found": "لم يتم العثور على مجلد"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "جهات الاتصال",
|
||||
"label_with_count": "جهات الاتصال ({{count}})",
|
||||
"any": "الكل",
|
||||
"search_placeholder": "بحث في {{field}}...",
|
||||
"reset_all": "إعادة تعيين جميع جهات الاتصال",
|
||||
"no_contact_found": "لم يتم العثور على جهات اتصال",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"showing_limit": "عرض أول 100 نتيجة فقط • المجموع {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "متقدم",
|
||||
"title": "فلاتر متقدمة",
|
||||
"reset": "إعادة تعيين",
|
||||
"has_attachment": "يحتوي على مرفق",
|
||||
"attachment_name_label": "اسم المرفق",
|
||||
"attachment_name_placeholder": "مثال: invoice.pdf",
|
||||
"message_size_label": "حجم الرسالة",
|
||||
"message_id_label": "معرّف Message-ID الأصلي",
|
||||
"message_id_description": "البحث باستخدام Message-ID في ترويسة البريد",
|
||||
"apply": "تطبيق الفلاتر",
|
||||
"size_presets": {
|
||||
"any": "أي حجم",
|
||||
"tiny": "صغير جداً (< 15 كيلوبايت)",
|
||||
"small": "صغير (< 2 ميغابايت)",
|
||||
"medium": "متوسط (2 – 10 ميغابايت)",
|
||||
"large": "كبير (10 – 20 ميغابايت)",
|
||||
"huge": "ضخم (> 20 ميغابايت)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "العرض",
|
||||
"menu_title": "إعدادات الأعمدة"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "إعادة تعيين",
|
||||
"tooltip": "مسح جميع شروط التصفية"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "البحث في البريد... (استخدم \"علامات الاقتباس\" للمطابقة التامة)",
|
||||
"button": "بحث",
|
||||
"recent_title": "عمليات البحث الأخيرة",
|
||||
"clear_history": "مسح السجل",
|
||||
"no_history": "لا يوجد سجل بحث",
|
||||
"hint": "نطاق البحث الافتراضي: العنوان، المحتوى، وأسماء المرفقات"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Alle",
|
||||
"tiny": "Meget lille (<15 KB)",
|
||||
"small": "Lille (<2 MB)",
|
||||
"medium": "Mellem (<20 MB)",
|
||||
"large": "Stor (≥20 MB)",
|
||||
"medium": "Mellem (2 - 10 MB)",
|
||||
"large": "Stor (10 - 20 MB)",
|
||||
"huge": "Kæmpestor (≥20 MB)",
|
||||
"sort": "Sortér",
|
||||
"sizeDescription": "Størrelsen henviser til den samlede e-mailstørrelse, inklusive vedhæftede filer.",
|
||||
"title": "Søg",
|
||||
"searching": "Søger, vent venligst...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Vælg venligst en konto",
|
||||
"role_required": "Vælg venligst en rolle",
|
||||
"username_required": "Brugernavn er påkrævet",
|
||||
"username_min": "Mindst 5 tegn",
|
||||
"username_min": "Mindst 3 tegn",
|
||||
"username_max": "Højst 32 tegn",
|
||||
"email_required": "E-mail er påkrævet",
|
||||
"email_invalid": "Indtast en gyldig e-mail",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "De valgte meddelelser er blevet gendannet til IMAP-serveren.",
|
||||
"failed": "Kunne ikke gendanne meddelelser",
|
||||
"failedTitle": "Gendannelse mislykkedes"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tid",
|
||||
"since": "Siden",
|
||||
"before": "Før",
|
||||
"recent_range": "Seneste interval (siden...)",
|
||||
"historical": "Historisk interval (før...)",
|
||||
"absolute_range": "Absolut datointerval",
|
||||
"last_day": "Seneste 1 dag",
|
||||
"last_days": "Seneste {{count}} dage",
|
||||
"last_months": "Seneste {{count}} måneder",
|
||||
"over_years_ago": "For {{count}} {{unit}} siden",
|
||||
"year": "år",
|
||||
"years": "år",
|
||||
"recent_prefix": "Seneste:",
|
||||
"days_ago_to_now": "dage siden til nu",
|
||||
"apply": "Anvend",
|
||||
"start_date": "Startdato",
|
||||
"end_date": "Slutdato",
|
||||
"clear_filters": "Ryd tidsfiltre",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tags",
|
||||
"search_placeholder": "Søg tags...",
|
||||
"clear_all": "Ryd alle tags",
|
||||
"no_tags_found": "Ingen tags fundet"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Mailkonti",
|
||||
"search_placeholder": "Søg mailkonti...",
|
||||
"clear_accounts": "Ryd valgte konti",
|
||||
"no_accounts_found": "Ingen konti fundet"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Postkasser",
|
||||
"search_placeholder": "Søg postkasser",
|
||||
"clear_mailboxes": "Ryd valgte mapper",
|
||||
"select_account_first": "Vælg venligst en mailkonto først",
|
||||
"no_mailbox_found": "Ingen mappe fundet"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakter",
|
||||
"label_with_count": "Kontakter ({{count}})",
|
||||
"any": "Alle",
|
||||
"search_placeholder": "Søg i {{field}}...",
|
||||
"reset_all": "Nulstil alle kontakter",
|
||||
"no_contact_found": "Ingen kontakter fundet",
|
||||
"loading": "Indlæser...",
|
||||
"showing_limit": "Viser kun de første 100 resultater • I alt {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avanceret",
|
||||
"title": "Avancerede filtre",
|
||||
"reset": "Nulstil",
|
||||
"has_attachment": "Har vedhæftning",
|
||||
"attachment_name_label": "Navn på vedhæftning",
|
||||
"attachment_name_placeholder": "F.eks.: invoice.pdf",
|
||||
"message_size_label": "Mailstørrelse",
|
||||
"message_id_label": "Original Message-ID",
|
||||
"message_id_description": "Søg via Message-ID i mailheaderen",
|
||||
"apply": "Anvend filtre",
|
||||
"size_presets": {
|
||||
"any": "Alle størrelser",
|
||||
"tiny": "Meget lille (< 15 KB)",
|
||||
"small": "Lille (< 2 MB)",
|
||||
"medium": "Mellem (2 – 10 MB)",
|
||||
"large": "Stor (10 – 20 MB)",
|
||||
"huge": "Meget stor (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Visning",
|
||||
"menu_title": "Indstillinger for kolonner"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Nulstil",
|
||||
"tooltip": "Ryd alle filtre"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Søg i mails... (brug \"anførselstegn\" til nøjagtig match)",
|
||||
"button": "Søg",
|
||||
"recent_title": "Seneste søgninger",
|
||||
"clear_history": "Ryd historik",
|
||||
"no_history": "Ingen søgehistorik",
|
||||
"hint": "Standard søgeområde: emne, indhold og vedhæftningsnavne"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Beliebig",
|
||||
"tiny": "Sehr klein (<15 KB)",
|
||||
"small": "Klein (<2 MB)",
|
||||
"medium": "Mittel (<20 MB)",
|
||||
"large": "Groß (≥20 MB)",
|
||||
"medium": "Mittel (2 - 10 MB)",
|
||||
"large": "Groß (10 - 20 MB)",
|
||||
"huge": "Riesig (≥20 MB)",
|
||||
"sort": "Sortieren",
|
||||
"sizeDescription": "Die Größe bezieht sich auf die gesamte E-Mail inklusive Anhängen.",
|
||||
"title": "Suchen",
|
||||
"searching": "Wird gesucht, bitte warten Sie...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Bitte wählen Sie ein Konto",
|
||||
"role_required": "Bitte wählen Sie eine Rolle",
|
||||
"username_required": "Benutzername ist erforderlich",
|
||||
"username_min": "Benutzername muss mindestens 5 Zeichen lang sein",
|
||||
"username_min": "Benutzername muss mindestens 3 Zeichen lang sein",
|
||||
"username_max": "Benutzername darf maximal 32 Zeichen lang sein",
|
||||
"email_required": "E-Mail-Adresse ist erforderlich",
|
||||
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "Die ausgewählten Nachrichten wurden erfolgreich auf dem IMAP-Server wiederhergestellt.",
|
||||
"failed": "Wiederherstellung fehlgeschlagen",
|
||||
"failedTitle": "Fehler bei der Wiederherstellung"
|
||||
},
|
||||
"time": {
|
||||
"label": "Zeit",
|
||||
"since": "Seit",
|
||||
"before": "Vor",
|
||||
"recent_range": "Letzter Zeitraum (seit …)",
|
||||
"historical": "Historischer Zeitraum (vor …)",
|
||||
"absolute_range": "Absoluter Datumsbereich",
|
||||
"last_day": "Letzter 1 Tag",
|
||||
"last_days": "Letzte {{count}} Tage",
|
||||
"last_months": "Letzte {{count}} Monate",
|
||||
"over_years_ago": "Vor {{count}} {{unit}}",
|
||||
"year": "Jahr",
|
||||
"years": "Jahre",
|
||||
"recent_prefix": "Kürzlich:",
|
||||
"days_ago_to_now": "Tage bis heute",
|
||||
"apply": "Anwenden",
|
||||
"start_date": "Startdatum",
|
||||
"end_date": "Enddatum",
|
||||
"clear_filters": "Zeitfilter löschen",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tags",
|
||||
"search_placeholder": "Tags suchen...",
|
||||
"clear_all": "Alle Tags löschen",
|
||||
"no_tags_found": "Keine Tags gefunden"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "E-Mail-Konten",
|
||||
"search_placeholder": "E-Mail-Konten suchen...",
|
||||
"clear_accounts": "Ausgewählte Konten löschen",
|
||||
"no_accounts_found": "Keine Konten gefunden"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Postfächer",
|
||||
"search_placeholder": "Postfächer durchsuchen",
|
||||
"clear_mailboxes": "Ausgewählte Ordner löschen",
|
||||
"select_account_first": "Bitte zuerst ein E-Mail-Konto auswählen",
|
||||
"no_mailbox_found": "Kein Ordner gefunden"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakte",
|
||||
"label_with_count": "Kontakte ({{count}})",
|
||||
"any": "Alle",
|
||||
"search_placeholder": "{{field}} suchen...",
|
||||
"reset_all": "Alle Kontakte zurücksetzen",
|
||||
"no_contact_found": "Keine Kontakte gefunden",
|
||||
"loading": "Wird geladen...",
|
||||
"showing_limit": "Nur die ersten 100 Ergebnisse angezeigt • Gesamt {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Erweitert",
|
||||
"title": "Erweiterte Filter",
|
||||
"reset": "Zurücksetzen",
|
||||
"has_attachment": "Mit Anhang",
|
||||
"attachment_name_label": "Anhangname",
|
||||
"attachment_name_placeholder": "z. B. invoice.pdf",
|
||||
"message_size_label": "Nachrichtengröße",
|
||||
"message_id_label": "Originale Message-ID",
|
||||
"message_id_description": "Suche über die Message-ID im E-Mail-Header",
|
||||
"apply": "Filter anwenden",
|
||||
"size_presets": {
|
||||
"any": "Beliebige Größe",
|
||||
"tiny": "Sehr klein (< 15 KB)",
|
||||
"small": "Klein (< 2 MB)",
|
||||
"medium": "Mittel (2 – 10 MB)",
|
||||
"large": "Groß (10 – 20 MB)",
|
||||
"huge": "Sehr groß (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Ansicht",
|
||||
"menu_title": "Spalteneinstellungen"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Zurücksetzen",
|
||||
"tooltip": "Alle Filter entfernen"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "E-Mails durchsuchen... (\"Anführungszeichen\" für exakte Übereinstimmung)",
|
||||
"button": "Suchen",
|
||||
"recent_title": "Letzte Suchanfragen",
|
||||
"clear_history": "Verlauf löschen",
|
||||
"no_history": "Kein Suchverlauf",
|
||||
"hint": "Standard-Suchbereich: Betreff, Inhalt und Anhangnamen"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Any",
|
||||
"tiny": "Tiny (<15 KB)",
|
||||
"small": "Small (<2 MB)",
|
||||
"medium": "Medium (<20 MB)",
|
||||
"large": "Large (≥20 MB)",
|
||||
"medium": "Medium (2 - 10 MB)",
|
||||
"large": "Large (10 - 20 MB)",
|
||||
"huge": "Huge (≥20 MB)",
|
||||
"sort": "Sort",
|
||||
"sizeDescription": "The size refers to the total email size, including attachments.",
|
||||
"title": "Search",
|
||||
"searching": "Searching, please wait…",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Please select an account",
|
||||
"role_required": "Please select a role",
|
||||
"username_required": "Username is required",
|
||||
"username_min": "Username must be at least 5 characters",
|
||||
"username_min": "Username must be at least 3 characters",
|
||||
"username_max": "Username cannot exceed 32 characters",
|
||||
"email_required": "Email address is required",
|
||||
"email_invalid": "Please enter a valid email address",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "The selected messages have been successfully restored to the IMAP server.",
|
||||
"failed": "Failed to restore messages",
|
||||
"failedTitle": "Restore Failed"
|
||||
},
|
||||
"time": {
|
||||
"label": "Time",
|
||||
"since": "Since",
|
||||
"before": "Before",
|
||||
"recent_range": "Recent range (since...)",
|
||||
"historical": "Historical range (before...)",
|
||||
"absolute_range": "Absolute date range",
|
||||
"last_day": "Last 1 day",
|
||||
"last_days": "Last {{count}} days",
|
||||
"last_months": "Last {{count}} months",
|
||||
"over_years_ago": "{{count}} {{unit}} ago",
|
||||
"year": "year",
|
||||
"years": "years",
|
||||
"recent_prefix": "Recent:",
|
||||
"days_ago_to_now": "days ago to now",
|
||||
"apply": "Apply",
|
||||
"start_date": "Start date",
|
||||
"end_date": "End date",
|
||||
"clear_filters": "Clear time filters",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tags",
|
||||
"search_placeholder": "Search tags...",
|
||||
"clear_all": "Clear all tags",
|
||||
"no_tags_found": "No tags found"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Mail accounts",
|
||||
"search_placeholder": "Search mail accounts...",
|
||||
"clear_accounts": "Clear selected accounts",
|
||||
"no_accounts_found": "No accounts found"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Mailboxes",
|
||||
"search_placeholder": "Search mailboxes",
|
||||
"clear_mailboxes": "Clear selected mailboxes",
|
||||
"select_account_first": "Please select an account first",
|
||||
"no_mailbox_found": "No mailbox found"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contacts",
|
||||
"label_with_count": "Contacts ({{count}})",
|
||||
"any": "Any",
|
||||
"search_placeholder": "Search {{field}}...",
|
||||
"reset_all": "Reset all contacts",
|
||||
"no_contact_found": "No contacts found",
|
||||
"loading": "Loading...",
|
||||
"showing_limit": "Showing first 100 results • Total {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Advanced",
|
||||
"title": "Advanced filters",
|
||||
"reset": "Reset",
|
||||
"has_attachment": "Has attachment",
|
||||
"attachment_name_label": "Attachment name",
|
||||
"attachment_name_placeholder": "e.g. invoice.pdf",
|
||||
"message_size_label": "Message size",
|
||||
"message_id_label": "Original Message-ID",
|
||||
"message_id_description": "Search by Message-ID header",
|
||||
"apply": "Apply filters",
|
||||
"size_presets": {
|
||||
"any": "Any size",
|
||||
"tiny": "Tiny (< 15 KB)",
|
||||
"small": "Small (< 2 MB)",
|
||||
"medium": "Medium (2 – 10 MB)",
|
||||
"large": "Large (10 – 20 MB)",
|
||||
"huge": "Huge (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "View",
|
||||
"menu_title": "Column settings"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Reset",
|
||||
"tooltip": "Clear all filters"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Search emails... (use \"quotes\" for exact match)",
|
||||
"button": "Search",
|
||||
"recent_title": "Recent searches",
|
||||
"clear_history": "Clear history",
|
||||
"no_history": "No search history",
|
||||
"hint": "Default scope: subject, body and attachment names"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Cualquiera",
|
||||
"tiny": "Muy pequeño (<15 KB)",
|
||||
"small": "Pequeño (<2 MB)",
|
||||
"medium": "Mediano (<20 MB)",
|
||||
"large": "Grande (≥20 MB)",
|
||||
"medium": "Mediano (2 - 10 MB)",
|
||||
"large": "Grande (10 - 20 MB)",
|
||||
"huge": "Muy grande (≥20 MB)",
|
||||
"sort": "Ordenar",
|
||||
"sizeDescription": "El tamaño se refiere al tamaño total del correo electrónico, incluidos los archivos adjuntos.",
|
||||
"title": "Buscar",
|
||||
"searching": "Buscando, por favor espera...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Seleccione una cuenta",
|
||||
"role_required": "Seleccione un rol",
|
||||
"username_required": "El nombre de usuario es obligatorio",
|
||||
"username_min": "Mínimo 5 caracteres",
|
||||
"username_min": "Mínimo 3 caracteres",
|
||||
"username_max": "Máximo 32 caracteres",
|
||||
"email_required": "El correo es obligatorio",
|
||||
"email_invalid": "Ingrese un correo válido",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "Los mensajes seleccionados se han restaurado correctamente en el servidor IMAP.",
|
||||
"failed": "Error al restaurar los mensajes",
|
||||
"failedTitle": "Error de restauración"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tiempo",
|
||||
"since": "Desde",
|
||||
"before": "Antes de",
|
||||
"recent_range": "Rango reciente (desde...)",
|
||||
"historical": "Rango histórico (antes de...)",
|
||||
"absolute_range": "Rango de fechas absoluto",
|
||||
"last_day": "Último 1 día",
|
||||
"last_days": "Últimos {{count}} días",
|
||||
"last_months": "Últimos {{count}} meses",
|
||||
"over_years_ago": "Hace {{count}} {{unit}}",
|
||||
"year": "año",
|
||||
"years": "años",
|
||||
"recent_prefix": "Reciente:",
|
||||
"days_ago_to_now": "días atrás hasta ahora",
|
||||
"apply": "Aplicar",
|
||||
"start_date": "Fecha de inicio",
|
||||
"end_date": "Fecha de fin",
|
||||
"clear_filters": "Borrar filtros de tiempo",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Etiquetas",
|
||||
"search_placeholder": "Buscar etiquetas...",
|
||||
"clear_all": "Borrar todas las etiquetas",
|
||||
"no_tags_found": "No se encontraron etiquetas"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Cuentas de correo",
|
||||
"search_placeholder": "Buscar cuentas de correo...",
|
||||
"clear_accounts": "Borrar cuentas seleccionadas",
|
||||
"no_accounts_found": "No se encontraron cuentas"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Buzones",
|
||||
"search_placeholder": "Buscar buzones",
|
||||
"clear_mailboxes": "Borrar carpetas seleccionadas",
|
||||
"select_account_first": "Seleccione primero una cuenta de correo",
|
||||
"no_mailbox_found": "No se encontró ningún buzón"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contactos",
|
||||
"label_with_count": "Contactos ({{count}})",
|
||||
"any": "Cualquiera",
|
||||
"search_placeholder": "Buscar {{field}}...",
|
||||
"reset_all": "Restablecer todos los contactos",
|
||||
"no_contact_found": "No se encontraron contactos",
|
||||
"loading": "Cargando...",
|
||||
"showing_limit": "Mostrando solo los primeros 100 resultados • Total {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avanzado",
|
||||
"title": "Filtros avanzados",
|
||||
"reset": "Restablecer",
|
||||
"has_attachment": "Con archivo adjunto",
|
||||
"attachment_name_label": "Nombre del archivo adjunto",
|
||||
"attachment_name_placeholder": "Ej.: invoice.pdf",
|
||||
"message_size_label": "Tamaño del mensaje",
|
||||
"message_id_label": "Message-ID original",
|
||||
"message_id_description": "Buscar usando el encabezado Message-ID del correo",
|
||||
"apply": "Aplicar filtros",
|
||||
"size_presets": {
|
||||
"any": "Cualquier tamaño",
|
||||
"tiny": "Muy pequeño (< 15 KB)",
|
||||
"small": "Pequeño (< 2 MB)",
|
||||
"medium": "Mediano (2 – 10 MB)",
|
||||
"large": "Grande (10 – 20 MB)",
|
||||
"huge": "Muy grande (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Vista",
|
||||
"menu_title": "Configuración de columnas"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Restablecer",
|
||||
"tooltip": "Eliminar todos los filtros"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Buscar correos... (usa \"comillas\" para coincidencia exacta)",
|
||||
"button": "Buscar",
|
||||
"recent_title": "Búsquedas recientes",
|
||||
"clear_history": "Borrar historial",
|
||||
"no_history": "Sin historial de búsqueda",
|
||||
"hint": "Ámbito de búsqueda predeterminado: asunto, contenido y nombres de archivos adjuntos"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Kaikki",
|
||||
"tiny": "Erittäin pieni (<15 KB)",
|
||||
"small": "Pieni (<2 MB)",
|
||||
"medium": "Keskikokoinen (<20 MB)",
|
||||
"large": "Suuri (≥20 MB)",
|
||||
"medium": "Keskikokoinen (2 - 10 MB)",
|
||||
"large": "Suuri (10 - 20 MB)",
|
||||
"huge": "Valtava (≥20 MB)",
|
||||
"sort": "Lajittele",
|
||||
"sizeDescription": "Koko tarkoittaa sähköpostin kokonaiskokoa, liitteet mukaan lukien.",
|
||||
"title": "Hae",
|
||||
"searching": "Haetaan, odota hetki...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Valitse tili",
|
||||
"role_required": "Valitse rooli",
|
||||
"username_required": "Käyttäjänimi on pakollinen",
|
||||
"username_min": "Vähintään 5 merkkiä",
|
||||
"username_min": "Vähintään 3 merkkiä",
|
||||
"username_max": "Enintään 32 merkkiä",
|
||||
"email_required": "Sähköposti on pakollinen",
|
||||
"email_invalid": "Anna kelvollinen sähköpostiosoite",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "Valitut viestit on palautettu onnistuneesti IMAP-palvelimelle.",
|
||||
"failed": "Viestien palautus epäonnistui",
|
||||
"failedTitle": "Palautus epäonnistui"
|
||||
},
|
||||
"time": {
|
||||
"label": "Aika",
|
||||
"since": "Alkaen",
|
||||
"before": "Ennen",
|
||||
"recent_range": "Viimeisin aikaväli (alkaen...)",
|
||||
"historical": "Historiallinen aikaväli (ennen...)",
|
||||
"absolute_range": "Kiinteä päivämääräväli",
|
||||
"last_day": "Viimeiset 1 päivä",
|
||||
"last_days": "Viimeiset {{count}} päivää",
|
||||
"last_months": "Viimeiset {{count}} kuukautta",
|
||||
"over_years_ago": "{{count}} {{unit}} sitten",
|
||||
"year": "vuosi",
|
||||
"years": "vuotta",
|
||||
"recent_prefix": "Viimeisin:",
|
||||
"days_ago_to_now": "päivää sitten – nyt",
|
||||
"apply": "Käytä",
|
||||
"start_date": "Aloituspäivä",
|
||||
"end_date": "Päättymispäivä",
|
||||
"clear_filters": "Tyhjennä aikasuodattimet",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tunnisteet",
|
||||
"search_placeholder": "Hae tunnisteita...",
|
||||
"clear_all": "Tyhjennä kaikki tunnisteet",
|
||||
"no_tags_found": "Tunnisteita ei löytynyt"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Sähköpostitilit",
|
||||
"search_placeholder": "Hae sähköpostitilejä...",
|
||||
"clear_accounts": "Poista valitut tilit",
|
||||
"no_accounts_found": "Tilejä ei löytynyt"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Postilaatikot",
|
||||
"search_placeholder": "Hae postilaatikoita",
|
||||
"clear_mailboxes": "Poista valitut kansiot",
|
||||
"select_account_first": "Valitse ensin sähköpostitili",
|
||||
"no_mailbox_found": "Kansiota ei löytynyt"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Yhteystiedot",
|
||||
"label_with_count": "Yhteystiedot ({{count}})",
|
||||
"any": "Kaikki",
|
||||
"search_placeholder": "Hae {{field}}...",
|
||||
"reset_all": "Palauta kaikki yhteystiedot",
|
||||
"no_contact_found": "Yhteystietoja ei löytynyt",
|
||||
"loading": "Ladataan...",
|
||||
"showing_limit": "Näytetään vain ensimmäiset 100 tulosta • Yhteensä {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Lisäasetukset",
|
||||
"title": "Lisäsuodattimet",
|
||||
"reset": "Palauta",
|
||||
"has_attachment": "Sisältää liitteen",
|
||||
"attachment_name_label": "Liitteen nimi",
|
||||
"attachment_name_placeholder": "Esim.: invoice.pdf",
|
||||
"message_size_label": "Viestin koko",
|
||||
"message_id_label": "Alkuperäinen Message-ID",
|
||||
"message_id_description": "Hae sähköpostin Message-ID-otsikon perusteella",
|
||||
"apply": "Käytä suodattimia",
|
||||
"size_presets": {
|
||||
"any": "Mikä tahansa koko",
|
||||
"tiny": "Erittäin pieni (< 15 KB)",
|
||||
"small": "Pieni (< 2 MB)",
|
||||
"medium": "Keskikokoinen (2 – 10 MB)",
|
||||
"large": "Suuri (10 – 20 MB)",
|
||||
"huge": "Erittäin suuri (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Näkymä",
|
||||
"menu_title": "Sarakeasetukset"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Palauta",
|
||||
"tooltip": "Poista kaikki suodattimet"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Hae sähköposteja... (käytä \"lainausmerkkejä\" tarkkaan hakuun)",
|
||||
"button": "Hae",
|
||||
"recent_title": "Viimeisimmät haut",
|
||||
"clear_history": "Tyhjennä historia",
|
||||
"no_history": "Ei hakuhistoriaa",
|
||||
"hint": "Oletushakualue: otsikko, sisältö ja liitteiden nimet"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Toutes",
|
||||
"tiny": "Très petite (<15 KB)",
|
||||
"small": "Petite (<2 MB)",
|
||||
"medium": "Moyenne (<20 MB)",
|
||||
"large": "Grande (≥20 MB)",
|
||||
"medium": "Moyenne (2 - 10 MB)",
|
||||
"large": "Grande (10 - 20 MB)",
|
||||
"huge": "Énorme (≥20 Mo)",
|
||||
"sort": "Trier",
|
||||
"sizeDescription": "La taille correspond à la taille totale de l’e-mail, pièces jointes incluses.",
|
||||
"title": "Recherche",
|
||||
"searching": "Recherche en cours, veuillez patienter...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Veuillez sélectionner un compte",
|
||||
"role_required": "Veuillez sélectionner un rôle",
|
||||
"username_required": "Le nom d'utilisateur est requis",
|
||||
"username_min": "Le nom d'utilisateur doit contenir au moins 5 caractères",
|
||||
"username_min": "Le nom d'utilisateur doit contenir au moins 3 caractères",
|
||||
"username_max": "Le nom d'utilisateur ne peut pas dépasser 32 caractères",
|
||||
"email_required": "L'adresse e-mail est requise",
|
||||
"email_invalid": "Veuillez saisir une adresse e-mail valide",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "Les messages sélectionnés ont été restaurés avec succès sur le serveur IMAP.",
|
||||
"failed": "Échec de la restauration des messages",
|
||||
"failedTitle": "Échec de la restauration"
|
||||
},
|
||||
"time": {
|
||||
"label": "Temps",
|
||||
"since": "Depuis",
|
||||
"before": "Avant",
|
||||
"recent_range": "Période récente (depuis...)",
|
||||
"historical": "Période historique (avant...)",
|
||||
"absolute_range": "Plage de dates absolue",
|
||||
"last_day": "Dernier jour",
|
||||
"last_days": "Derniers {{count}} jours",
|
||||
"last_months": "Derniers {{count}} mois",
|
||||
"over_years_ago": "Il y a {{count}} {{unit}}",
|
||||
"year": "an",
|
||||
"years": "ans",
|
||||
"recent_prefix": "Récent :",
|
||||
"days_ago_to_now": "jours jusqu’à aujourd’hui",
|
||||
"apply": "Appliquer",
|
||||
"start_date": "Date de début",
|
||||
"end_date": "Date de fin",
|
||||
"clear_filters": "Effacer les filtres de temps",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Étiquettes",
|
||||
"search_placeholder": "Rechercher des étiquettes...",
|
||||
"clear_all": "Effacer toutes les étiquettes",
|
||||
"no_tags_found": "Aucune étiquette trouvée"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Comptes e-mail",
|
||||
"search_placeholder": "Rechercher des comptes e-mail...",
|
||||
"clear_accounts": "Effacer les comptes sélectionnés",
|
||||
"no_accounts_found": "Aucun compte trouvé"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Boîtes mail",
|
||||
"search_placeholder": "Rechercher des dossiers",
|
||||
"clear_mailboxes": "Effacer les dossiers sélectionnés",
|
||||
"select_account_first": "Veuillez d’abord sélectionner un compte e-mail",
|
||||
"no_mailbox_found": "Aucun dossier trouvé"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contacts",
|
||||
"label_with_count": "Contacts ({{count}})",
|
||||
"any": "Tous",
|
||||
"search_placeholder": "Rechercher {{field}}...",
|
||||
"reset_all": "Réinitialiser tous les contacts",
|
||||
"no_contact_found": "Aucun contact trouvé",
|
||||
"loading": "Chargement...",
|
||||
"showing_limit": "Affichage des 100 premiers résultats uniquement • Total {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avancé",
|
||||
"title": "Filtres avancés",
|
||||
"reset": "Réinitialiser",
|
||||
"has_attachment": "Contient une pièce jointe",
|
||||
"attachment_name_label": "Nom de la pièce jointe",
|
||||
"attachment_name_placeholder": "Ex. : invoice.pdf",
|
||||
"message_size_label": "Taille du message",
|
||||
"message_id_label": "Message-ID d’origine",
|
||||
"message_id_description": "Recherche via l’en-tête Message-ID de l’e-mail",
|
||||
"apply": "Appliquer les filtres",
|
||||
"size_presets": {
|
||||
"any": "Toutes tailles",
|
||||
"tiny": "Très petite (< 15 Ko)",
|
||||
"small": "Petite (< 2 Mo)",
|
||||
"medium": "Moyenne (2 – 10 Mo)",
|
||||
"large": "Grande (10 – 20 Mo)",
|
||||
"huge": "Très grande (> 20 Mo)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Affichage",
|
||||
"menu_title": "Paramètres des colonnes"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Réinitialiser",
|
||||
"tooltip": "Effacer tous les filtres"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Rechercher des e-mails... (utilisez les \"guillemets\" pour une correspondance exacte)",
|
||||
"button": "Rechercher",
|
||||
"recent_title": "Recherches récentes",
|
||||
"clear_history": "Effacer l’historique",
|
||||
"no_history": "Aucun historique de recherche",
|
||||
"hint": "Portée par défaut : objet, contenu et noms des pièces jointes"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Qualsiasi",
|
||||
"tiny": "Molto piccolo (<15 KB)",
|
||||
"small": "Piccolo (<2 MB)",
|
||||
"medium": "Medio (<20 MB)",
|
||||
"large": "Grande (≥20 MB)",
|
||||
"medium": "Medio (2 - 10 MB)",
|
||||
"large": "Grande (10 - 20 MB)",
|
||||
"huge": "Enorme (≥20 MB)",
|
||||
"sort": "Ordina",
|
||||
"sizeDescription": "La dimensione indica la dimensione totale dell’email, inclusi gli allegati.",
|
||||
"title": "Cerca",
|
||||
"searching": "Ricerca in corso, attendere prego...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Seleziona un account",
|
||||
"role_required": "Seleziona un ruolo",
|
||||
"username_required": "Il nome utente è obbligatorio",
|
||||
"username_min": "Minimo 5 caratteri",
|
||||
"username_min": "Minimo 3 caratteri",
|
||||
"username_max": "Massimo 32 caratteri",
|
||||
"email_required": "L'email è obbligatoria",
|
||||
"email_invalid": "Inserisci un'email valida",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "I messaggi selezionati sono stati ripristinati con successo sul server IMAP.",
|
||||
"failed": "Impossibile ripristinare i messaggi",
|
||||
"failedTitle": "Ripristino fallito"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tempo",
|
||||
"since": "Da",
|
||||
"before": "Prima di",
|
||||
"recent_range": "Intervallo recente (da...)",
|
||||
"historical": "Intervallo storico (prima di...)",
|
||||
"absolute_range": "Intervallo di date assoluto",
|
||||
"last_day": "Ultimo 1 giorno",
|
||||
"last_days": "Ultimi {{count}} giorni",
|
||||
"last_months": "Ultimi {{count}} mesi",
|
||||
"over_years_ago": "{{count}} {{unit}} fa",
|
||||
"year": "anno",
|
||||
"years": "anni",
|
||||
"recent_prefix": "Recenti:",
|
||||
"days_ago_to_now": "giorni fa fino ad oggi",
|
||||
"apply": "Applica",
|
||||
"start_date": "Data di inizio",
|
||||
"end_date": "Data di fine",
|
||||
"clear_filters": "Cancella filtri temporali",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tag",
|
||||
"search_placeholder": "Cerca tag...",
|
||||
"clear_all": "Cancella tutti i tag",
|
||||
"no_tags_found": "Nessun tag trovato"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Account email",
|
||||
"search_placeholder": "Cerca account email...",
|
||||
"clear_accounts": "Cancella account selezionati",
|
||||
"no_accounts_found": "Nessun account trovato"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Caselle di posta",
|
||||
"search_placeholder": "Cerca cartelle",
|
||||
"clear_mailboxes": "Cancella cartelle selezionate",
|
||||
"select_account_first": "Seleziona prima un account email",
|
||||
"no_mailbox_found": "Nessuna cartella trovata"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contatti",
|
||||
"label_with_count": "Contatti ({{count}})",
|
||||
"any": "Qualsiasi",
|
||||
"search_placeholder": "Cerca {{field}}...",
|
||||
"reset_all": "Reimposta tutti i contatti",
|
||||
"no_contact_found": "Nessun contatto trovato",
|
||||
"loading": "Caricamento...",
|
||||
"showing_limit": "Mostrati solo i primi 100 risultati • Totale {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avanzato",
|
||||
"title": "Filtri avanzati",
|
||||
"reset": "Reimposta",
|
||||
"has_attachment": "Con allegato",
|
||||
"attachment_name_label": "Nome allegato",
|
||||
"attachment_name_placeholder": "Es.: invoice.pdf",
|
||||
"message_size_label": "Dimensione email",
|
||||
"message_id_label": "Message-ID originale",
|
||||
"message_id_description": "Ricerca tramite Message-ID nell’intestazione email",
|
||||
"apply": "Applica filtri",
|
||||
"size_presets": {
|
||||
"any": "Qualsiasi dimensione",
|
||||
"tiny": "Molto piccola (< 15 KB)",
|
||||
"small": "Piccola (< 2 MB)",
|
||||
"medium": "Media (2 – 10 MB)",
|
||||
"large": "Grande (10 – 20 MB)",
|
||||
"huge": "Molto grande (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Vista",
|
||||
"menu_title": "Impostazioni colonne"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Reimposta",
|
||||
"tooltip": "Cancella tutti i filtri"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Cerca email... (usa le \"virgolette\" per corrispondenza esatta)",
|
||||
"button": "Cerca",
|
||||
"recent_title": "Ricerche recenti",
|
||||
"clear_history": "Cancella cronologia",
|
||||
"no_history": "Nessuna cronologia di ricerca",
|
||||
"hint": "Ambito predefinito: oggetto, contenuto e nomi degli allegati"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "指定なし",
|
||||
"tiny": "極小(15 KB 未満)",
|
||||
"small": "小(2 MB 未満)",
|
||||
"medium": "中(20 MB 未満)",
|
||||
"large": "大(20 MB 以上)",
|
||||
"medium": "中(2 - 10 MB)",
|
||||
"large": "大(10 - 20 MB)",
|
||||
"huge": "巨大 (≥20 MB)",
|
||||
"sort": "並べ替え",
|
||||
"sizeDescription": "サイズは添付ファイルを含むメール全体の容量を指します。",
|
||||
"title": "検索",
|
||||
"searching": "検索中です。お待ちください…",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "アカウントを選択してください",
|
||||
"role_required": "ロールを選択してください",
|
||||
"username_required": "ユーザー名は必須です",
|
||||
"username_min": "ユーザー名は 5 文字以上である必要があります",
|
||||
"username_min": "ユーザー名は 3 文字以上である必要があります",
|
||||
"username_max": "ユーザー名は 32 文字以内である必要があります",
|
||||
"email_required": "メールアドレスは必須です",
|
||||
"email_invalid": "有効なメールアドレスを入力してください",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "選択したメッセージがIMAPサーバーに正常に復元されました。",
|
||||
"failed": "メッセージの復元に失敗しました",
|
||||
"failedTitle": "復元失敗"
|
||||
},
|
||||
"time": {
|
||||
"label": "時間",
|
||||
"since": "以降",
|
||||
"before": "以前",
|
||||
"recent_range": "最近の範囲(以降)",
|
||||
"historical": "過去の範囲(以前)",
|
||||
"absolute_range": "絶対日付範囲",
|
||||
"last_day": "過去 1 日",
|
||||
"last_days": "過去 {{count}} 日",
|
||||
"last_months": "過去 {{count}} か月",
|
||||
"over_years_ago": "{{count}}{{unit}}前",
|
||||
"year": "年",
|
||||
"years": "年",
|
||||
"recent_prefix": "最近:",
|
||||
"days_ago_to_now": "日前〜現在",
|
||||
"apply": "適用",
|
||||
"start_date": "開始日",
|
||||
"end_date": "終了日",
|
||||
"clear_filters": "時間フィルターをクリア",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "タグ",
|
||||
"search_placeholder": "タグを検索...",
|
||||
"clear_all": "すべてのタグをクリア",
|
||||
"no_tags_found": "タグが見つかりません"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "メールアカウント",
|
||||
"search_placeholder": "アカウントを検索...",
|
||||
"clear_accounts": "選択したアカウントをクリア",
|
||||
"no_accounts_found": "アカウントが見つかりません"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "メールボックス",
|
||||
"search_placeholder": "メールボックスを検索",
|
||||
"clear_mailboxes": "選択したフォルダをクリア",
|
||||
"select_account_first": "先にアカウントを選択してください",
|
||||
"no_mailbox_found": "フォルダが見つかりません"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "連絡先",
|
||||
"label_with_count": "連絡先 ({{count}})",
|
||||
"any": "指定なし",
|
||||
"search_placeholder": "{{field}} を検索...",
|
||||
"reset_all": "連絡先をリセット",
|
||||
"no_contact_found": "連絡先が見つかりません",
|
||||
"loading": "読み込み中...",
|
||||
"showing_limit": "最初の100件のみ表示 • 合計 {{total}} 件"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "詳細",
|
||||
"title": "詳細フィルター",
|
||||
"reset": "リセット",
|
||||
"has_attachment": "添付ファイルあり",
|
||||
"attachment_name_label": "添付ファイル名",
|
||||
"attachment_name_placeholder": "例: invoice.pdf",
|
||||
"message_size_label": "メールサイズ",
|
||||
"message_id_label": "Message-ID",
|
||||
"message_id_description": "メールヘッダーの Message-ID で検索",
|
||||
"apply": "適用",
|
||||
"size_presets": {
|
||||
"any": "指定なし",
|
||||
"tiny": "極小 (< 15 KB)",
|
||||
"small": "小 (< 2 MB)",
|
||||
"medium": "中 (2 – 10 MB)",
|
||||
"large": "大 (10 – 20 MB)",
|
||||
"huge": "特大 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "表示",
|
||||
"menu_title": "列の設定"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "リセット",
|
||||
"tooltip": "すべての条件をクリア"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "メールを検索…(\"引用符\"で完全一致)",
|
||||
"button": "検索",
|
||||
"recent_title": "最近の検索",
|
||||
"clear_history": "履歴をクリア",
|
||||
"no_history": "履歴なし",
|
||||
"hint": "検索対象:件名・本文・添付ファイル名"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "전체",
|
||||
"tiny": "아주 작음 (<15 KB)",
|
||||
"small": "작음 (<2 MB)",
|
||||
"medium": "중간 (<20 MB)",
|
||||
"large": "큼 (≥20 MB)",
|
||||
"medium": "중간 (2 - 10 MB)",
|
||||
"large": "큼 (10 - 20 MB)",
|
||||
"huge": "대용량 (≥20 MB)",
|
||||
"sort": "정렬",
|
||||
"sizeDescription": "크기는 첨부 파일을 포함한 이메일 전체 크기를 의미합니다.",
|
||||
"title": "검색",
|
||||
"searching": "검색 중입니다. 잠시 기다려 주십시오...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "계정을 선택하세요",
|
||||
"role_required": "역할을 선택하세요",
|
||||
"username_required": "사용자 이름은 필수입니다",
|
||||
"username_min": "사용자 이름은 5자 이상이어야 합니다",
|
||||
"username_min": "사용자 이름은 3자 이상이어야 합니다",
|
||||
"username_max": "사용자 이름은 32자를 초과할 수 없습니다",
|
||||
"email_required": "이메일 주소는 필수입니다",
|
||||
"email_invalid": "유효한 이메일 주소를 입력하세요",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "선택한 메시지가 IMAP 서버로 성공적으로 복원되었습니다.",
|
||||
"failed": "메시지 복원 실패",
|
||||
"failedTitle": "복원 실패"
|
||||
},
|
||||
"time": {
|
||||
"label": "시간",
|
||||
"since": "이후",
|
||||
"before": "이전",
|
||||
"recent_range": "최근 범위 (이후...)",
|
||||
"historical": "과거 범위 (이전...)",
|
||||
"absolute_range": "절대 날짜 범위",
|
||||
"last_day": "최근 1일",
|
||||
"last_days": "최근 {{count}}일",
|
||||
"last_months": "최근 {{count}}개월",
|
||||
"over_years_ago": "{{count}}{{unit}} 전",
|
||||
"year": "년",
|
||||
"years": "년",
|
||||
"recent_prefix": "최근:",
|
||||
"days_ago_to_now": "며칠 전부터 현재까지",
|
||||
"apply": "적용",
|
||||
"start_date": "시작 날짜",
|
||||
"end_date": "종료 날짜",
|
||||
"clear_filters": "시간 필터 초기화",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "태그",
|
||||
"search_placeholder": "태그 검색...",
|
||||
"clear_all": "모든 태그 지우기",
|
||||
"no_tags_found": "태그를 찾을 수 없습니다"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "메일 계정",
|
||||
"search_placeholder": "메일 계정 검색...",
|
||||
"clear_accounts": "선택한 계정 지우기",
|
||||
"no_accounts_found": "계정을 찾을 수 없습니다"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "메일함",
|
||||
"search_placeholder": "메일함 검색",
|
||||
"clear_mailboxes": "선택한 폴더 지우기",
|
||||
"select_account_first": "먼저 메일 계정을 선택하세요",
|
||||
"no_mailbox_found": "폴더를 찾을 수 없습니다"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "연락처",
|
||||
"label_with_count": "연락처 ({{count}})",
|
||||
"any": "제한 없음",
|
||||
"search_placeholder": "{{field}} 검색...",
|
||||
"reset_all": "모든 연락처 초기화",
|
||||
"no_contact_found": "연락처를 찾을 수 없습니다",
|
||||
"loading": "불러오는 중...",
|
||||
"showing_limit": "상위 100개 결과만 표시 • 전체 {{total}}개"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "고급",
|
||||
"title": "고급 필터",
|
||||
"reset": "초기화",
|
||||
"has_attachment": "첨부파일 포함",
|
||||
"attachment_name_label": "첨부파일 이름",
|
||||
"attachment_name_placeholder": "예: invoice.pdf",
|
||||
"message_size_label": "메일 크기",
|
||||
"message_id_label": "원본 Message-ID",
|
||||
"message_id_description": "메일 헤더의 Message-ID로 검색",
|
||||
"apply": "필터 적용",
|
||||
"size_presets": {
|
||||
"any": "크기 제한 없음",
|
||||
"tiny": "매우 작음 (< 15 KB)",
|
||||
"small": "작음 (< 2 MB)",
|
||||
"medium": "보통 (2 – 10 MB)",
|
||||
"large": "큼 (10 – 20 MB)",
|
||||
"huge": "매우 큼 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "보기",
|
||||
"menu_title": "표시 열 설정"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "초기화",
|
||||
"tooltip": "모든 필터 조건 지우기"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "메일 검색... (\"따옴표\"로 정확히 일치)",
|
||||
"button": "검색",
|
||||
"recent_title": "최근 검색",
|
||||
"clear_history": "기록 지우기",
|
||||
"no_history": "검색 기록 없음",
|
||||
"hint": "기본 검색 범위: 제목, 본문 및 첨부파일 이름"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Alle",
|
||||
"tiny": "Zeer klein (<15 KB)",
|
||||
"small": "Klein (<2 MB)",
|
||||
"medium": "Middelgroot (<20 MB)",
|
||||
"large": "Groot (≥20 MB)",
|
||||
"medium": "Middelgroot (2 - 10 MB)",
|
||||
"large": "Groot (10 - 20 MB)",
|
||||
"huge": "Zeer groot (≥20 MB)",
|
||||
"sort": "Sorteren",
|
||||
"sizeDescription": "De grootte verwijst naar de totale e-mailgrootte, inclusief bijlagen.",
|
||||
"title": "Zoeken",
|
||||
"searching": "Bezig met zoeken, even geduld alstublieft…",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Selecteer een account",
|
||||
"role_required": "Selecteer een rol",
|
||||
"username_required": "Gebruikersnaam is verplicht",
|
||||
"username_min": "Minimaal 5 tekens",
|
||||
"username_min": "Minimaal 3 tekens",
|
||||
"username_max": "Maximaal 32 tekens",
|
||||
"email_required": "E-mail is verplicht",
|
||||
"email_invalid": "Voer een geldig e-mailadres in",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "De geselecteerde berichten zijn succesvol hersteld op de IMAP-server.",
|
||||
"failed": "Herstellen van berichten mislukt",
|
||||
"failedTitle": "Herstel mislukt"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tijd",
|
||||
"since": "Sinds",
|
||||
"before": "Voor",
|
||||
"recent_range": "Recente periode (sinds...)",
|
||||
"historical": "Historische periode (voor...)",
|
||||
"absolute_range": "Absolute datumbereik",
|
||||
"last_day": "Laatste 1 dag",
|
||||
"last_days": "Laatste {{count}} dagen",
|
||||
"last_months": "Laatste {{count}} maanden",
|
||||
"over_years_ago": "{{count}} {{unit}} geleden",
|
||||
"year": "jaar",
|
||||
"years": "jaar",
|
||||
"recent_prefix": "Recent:",
|
||||
"days_ago_to_now": "dagen geleden tot nu",
|
||||
"apply": "Toepassen",
|
||||
"start_date": "Startdatum",
|
||||
"end_date": "Einddatum",
|
||||
"clear_filters": "Tijdfilters wissen",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tags",
|
||||
"search_placeholder": "Tags zoeken...",
|
||||
"clear_all": "Alle tags wissen",
|
||||
"no_tags_found": "Geen tags gevonden"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "E-mailaccounts",
|
||||
"search_placeholder": "E-mailaccounts zoeken...",
|
||||
"clear_accounts": "Geselecteerde accounts wissen",
|
||||
"no_accounts_found": "Geen accounts gevonden"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Mailboxen",
|
||||
"search_placeholder": "Mailboxen zoeken",
|
||||
"clear_mailboxes": "Geselecteerde mappen wissen",
|
||||
"select_account_first": "Selecteer eerst een e-mailaccount",
|
||||
"no_mailbox_found": "Geen mailbox gevonden"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contacten",
|
||||
"label_with_count": "Contacten ({{count}})",
|
||||
"any": "Alle",
|
||||
"search_placeholder": "{{field}} zoeken...",
|
||||
"reset_all": "Alle contacten resetten",
|
||||
"no_contact_found": "Geen contacten gevonden",
|
||||
"loading": "Bezig met laden...",
|
||||
"showing_limit": "Alleen de eerste 100 resultaten • Totaal {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Geavanceerd",
|
||||
"title": "Geavanceerde filters",
|
||||
"reset": "Resetten",
|
||||
"has_attachment": "Bevat bijlage",
|
||||
"attachment_name_label": "Bijlagenaam",
|
||||
"attachment_name_placeholder": "Bijv.: invoice.pdf",
|
||||
"message_size_label": "Berichtgrootte",
|
||||
"message_id_label": "Originele Message-ID",
|
||||
"message_id_description": "Zoeken via de Message-ID in de e-mailheader",
|
||||
"apply": "Filters toepassen",
|
||||
"size_presets": {
|
||||
"any": "Elke grootte",
|
||||
"tiny": "Zeer klein (< 15 KB)",
|
||||
"small": "Klein (< 2 MB)",
|
||||
"medium": "Gemiddeld (2 – 10 MB)",
|
||||
"large": "Groot (10 – 20 MB)",
|
||||
"huge": "Zeer groot (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Weergave",
|
||||
"menu_title": "Kolominstellingen"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Resetten",
|
||||
"tooltip": "Alle filters wissen"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "E-mails zoeken... (gebruik \"aanhalingstekens\" voor exacte overeenkomst)",
|
||||
"button": "Zoeken",
|
||||
"recent_title": "Recente zoekopdrachten",
|
||||
"clear_history": "Geschiedenis wissen",
|
||||
"no_history": "Geen zoekgeschiedenis",
|
||||
"hint": "Standaard zoekbereik: onderwerp, inhoud en bijlagenamen"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Alle",
|
||||
"tiny": "Svært liten (<15 KB)",
|
||||
"small": "Liten (<2 MB)",
|
||||
"medium": "Middels (<20 MB)",
|
||||
"large": "Stor (≥20 MB)",
|
||||
"medium": "Middels (2 - 10 MB)",
|
||||
"large": "Stor (10 - 20 MB)",
|
||||
"huge": "Kjempestor (≥20 MB)",
|
||||
"sort": "Sorter",
|
||||
"sizeDescription": "Størrelsen viser til e-postens totale størrelse, inkludert vedlegg.",
|
||||
"title": "Søk",
|
||||
"searching": "Søker, vennligst vent…",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Vennligst velg en konto",
|
||||
"role_required": "Vennligst velg en rolle",
|
||||
"username_required": "Brukernavn er påkrevd",
|
||||
"username_min": "Minst 5 tegn",
|
||||
"username_min": "Minst 3 tegn",
|
||||
"username_max": "Maks 32 tegn",
|
||||
"email_required": "E-post er påkrevd",
|
||||
"email_invalid": "Oppgi en gyldig e-postadresse",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "De valgte meldingene har blitt gjenopprettet til IMAP-serveren.",
|
||||
"failed": "Kunne ikke gjenopprette meldinger",
|
||||
"failedTitle": "Gjenoppretting mislyktes"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tid",
|
||||
"since": "Siden",
|
||||
"before": "Før",
|
||||
"recent_range": "Nylig periode (siden...)",
|
||||
"historical": "Historisk periode (før...)",
|
||||
"absolute_range": "Absolutt datointervall",
|
||||
"last_day": "Siste 1 dag",
|
||||
"last_days": "Siste {{count}} dager",
|
||||
"last_months": "Siste {{count}} måneder",
|
||||
"over_years_ago": "For {{count}} {{unit}} siden",
|
||||
"year": "år",
|
||||
"years": "år",
|
||||
"recent_prefix": "Nylig:",
|
||||
"days_ago_to_now": "dager siden til nå",
|
||||
"apply": "Bruk",
|
||||
"start_date": "Startdato",
|
||||
"end_date": "Sluttdato",
|
||||
"clear_filters": "Fjern tidsfiltre",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Etiketter",
|
||||
"search_placeholder": "Søk etter etiketter...",
|
||||
"clear_all": "Fjern alle etiketter",
|
||||
"no_tags_found": "Ingen etiketter funnet"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "E-postkontoer",
|
||||
"search_placeholder": "Søk etter e-postkontoer...",
|
||||
"clear_accounts": "Fjern valgte kontoer",
|
||||
"no_accounts_found": "Ingen kontoer funnet"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Postbokser",
|
||||
"search_placeholder": "Søk i postbokser",
|
||||
"clear_mailboxes": "Fjern valgte mapper",
|
||||
"select_account_first": "Velg først en e-postkonto",
|
||||
"no_mailbox_found": "Ingen mappe funnet"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakter",
|
||||
"label_with_count": "Kontakter ({{count}})",
|
||||
"any": "Alle",
|
||||
"search_placeholder": "Søk i {{field}}...",
|
||||
"reset_all": "Tilbakestill alle kontakter",
|
||||
"no_contact_found": "Ingen kontakter funnet",
|
||||
"loading": "Laster...",
|
||||
"showing_limit": "Viser kun de første 100 resultatene • Totalt {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avansert",
|
||||
"title": "Avanserte filtre",
|
||||
"reset": "Tilbakestill",
|
||||
"has_attachment": "Har vedlegg",
|
||||
"attachment_name_label": "Vedleggsnavn",
|
||||
"attachment_name_placeholder": "F.eks.: invoice.pdf",
|
||||
"message_size_label": "Meldingsstørrelse",
|
||||
"message_id_label": "Opprinnelig Message-ID",
|
||||
"message_id_description": "Søk via Message-ID i e-postheaderen",
|
||||
"apply": "Bruk filtre",
|
||||
"size_presets": {
|
||||
"any": "Alle størrelser",
|
||||
"tiny": "Svært liten (< 15 KB)",
|
||||
"small": "Liten (< 2 MB)",
|
||||
"medium": "Middels (2 – 10 MB)",
|
||||
"large": "Stor (10 – 20 MB)",
|
||||
"huge": "Svært stor (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Visning",
|
||||
"menu_title": "Kolonneinnstillinger"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Tilbakestill",
|
||||
"tooltip": "Fjern alle filtre"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Søk i e-poster... (bruk \"anførselstegn\" for eksakt treff)",
|
||||
"button": "Søk",
|
||||
"recent_title": "Nylige søk",
|
||||
"clear_history": "Tøm historikk",
|
||||
"no_history": "Ingen søkehistorikk",
|
||||
"hint": "Standard søkeområde: emne, innhold og vedleggsnavn"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Dowolny",
|
||||
"tiny": "Bardzo mały (<15 KB)",
|
||||
"small": "Mały (<2 MB)",
|
||||
"medium": "Średni (<20 MB)",
|
||||
"large": "Duży (≥20 MB)",
|
||||
"medium": "Średni (2 - 10 MB)",
|
||||
"large": "Duży (10 - 20 MB)",
|
||||
"huge": "Bardzo duży (≥20 MB)",
|
||||
"sort": "Sortuj",
|
||||
"sizeDescription": "Rozmiar odnosi się do całkowitego rozmiaru wiadomości e-mail, łącznie z załącznikami.",
|
||||
"title": "Szukaj",
|
||||
"searching": "Wyszukiwanie, proszę czekać...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Wybierz konto",
|
||||
"role_required": "Wybierz rolę",
|
||||
"username_required": "Nazwa użytkownika jest wymagana",
|
||||
"username_min": "Min. 5 znaków",
|
||||
"username_min": "Min. 3 znaków",
|
||||
"username_max": "Maks. 32 znaki",
|
||||
"email_required": "E-mail jest wymagany",
|
||||
"email_invalid": "Wprowadź poprawny adres e-mail",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "Wybrane wiadomości zostały pomyślnie przywrócone na serwer IMAP.",
|
||||
"failed": "Nie udało się przywrócić wiadomości",
|
||||
"failedTitle": "Przywracanie nie powiodło się"
|
||||
},
|
||||
"time": {
|
||||
"label": "Czas",
|
||||
"since": "Od",
|
||||
"before": "Przed",
|
||||
"recent_range": "Ostatni zakres (od...)",
|
||||
"historical": "Zakres historyczny (przed...)",
|
||||
"absolute_range": "Bezwzględny zakres dat",
|
||||
"last_day": "Ostatni 1 dzień",
|
||||
"last_days": "Ostatnie {{count}} dni",
|
||||
"last_months": "Ostatnie {{count}} miesiące",
|
||||
"over_years_ago": "{{count}} {{unit}} temu",
|
||||
"year": "rok",
|
||||
"years": "lata",
|
||||
"recent_prefix": "Ostatnio:",
|
||||
"days_ago_to_now": "dni temu do teraz",
|
||||
"apply": "Zastosuj",
|
||||
"start_date": "Data rozpoczęcia",
|
||||
"end_date": "Data zakończenia",
|
||||
"clear_filters": "Wyczyść filtry czasu",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tagi",
|
||||
"search_placeholder": "Szukaj tagów...",
|
||||
"clear_all": "Wyczyść wszystkie tagi",
|
||||
"no_tags_found": "Nie znaleziono tagów"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Konta e-mail",
|
||||
"search_placeholder": "Szukaj kont e-mail...",
|
||||
"clear_accounts": "Wyczyść wybrane konta",
|
||||
"no_accounts_found": "Nie znaleziono kont"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Skrzynki pocztowe",
|
||||
"search_placeholder": "Szukaj skrzynek",
|
||||
"clear_mailboxes": "Wyczyść wybrane foldery",
|
||||
"select_account_first": "Najpierw wybierz konto e-mail",
|
||||
"no_mailbox_found": "Nie znaleziono folderu"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakty",
|
||||
"label_with_count": "Kontakty ({{count}})",
|
||||
"any": "Dowolne",
|
||||
"search_placeholder": "Szukaj {{field}}...",
|
||||
"reset_all": "Zresetuj wszystkie kontakty",
|
||||
"no_contact_found": "Nie znaleziono kontaktów",
|
||||
"loading": "Ładowanie...",
|
||||
"showing_limit": "Wyświetlane tylko pierwsze 100 wyników • Łącznie {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Zaawansowane",
|
||||
"title": "Filtry zaawansowane",
|
||||
"reset": "Resetuj",
|
||||
"has_attachment": "Zawiera załącznik",
|
||||
"attachment_name_label": "Nazwa załącznika",
|
||||
"attachment_name_placeholder": "Np.: invoice.pdf",
|
||||
"message_size_label": "Rozmiar wiadomości",
|
||||
"message_id_label": "Oryginalny Message-ID",
|
||||
"message_id_description": "Wyszukiwanie według nagłówka Message-ID",
|
||||
"apply": "Zastosuj filtry",
|
||||
"size_presets": {
|
||||
"any": "Dowolny rozmiar",
|
||||
"tiny": "Bardzo mały (< 15 KB)",
|
||||
"small": "Mały (< 2 MB)",
|
||||
"medium": "Średni (2 – 10 MB)",
|
||||
"large": "Duży (10 – 20 MB)",
|
||||
"huge": "Bardzo duży (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Widok",
|
||||
"menu_title": "Ustawienia kolumn"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Reset",
|
||||
"tooltip": "Wyczyść wszystkie filtry"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Szukaj e-maili... (użyj \"cudzysłowów\" dla dokładnego dopasowania)",
|
||||
"button": "Szukaj",
|
||||
"recent_title": "Ostatnie wyszukiwania",
|
||||
"clear_history": "Wyczyść historię",
|
||||
"no_history": "Brak historii wyszukiwania",
|
||||
"hint": "Domyślny zakres wyszukiwania: temat, treść i nazwy załączników"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Qualquer",
|
||||
"tiny": "Muito pequeno (<15 KB)",
|
||||
"small": "Pequeno (<2 MB)",
|
||||
"medium": "Médio (<20 MB)",
|
||||
"large": "Grande (≥20 MB)",
|
||||
"medium": "Médio (2 - 10 MB)",
|
||||
"large": "Grande (10 - 20 MB)",
|
||||
"huge": "Muito grande (≥20 MB)",
|
||||
"sort": "Ordenar",
|
||||
"sizeDescription": "O tamanho refere-se ao tamanho total do e-mail, incluindo anexos.",
|
||||
"title": "Pesquisa",
|
||||
"searching": "Pesquisando, por favor, aguarde...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Selecione uma conta",
|
||||
"role_required": "Selecione uma função",
|
||||
"username_required": "Nome de usuário é obrigatório",
|
||||
"username_min": "Mínimo 5 caracteres",
|
||||
"username_min": "Mínimo 3 caracteres",
|
||||
"username_max": "Máximo 32 caracteres",
|
||||
"email_required": "E-mail é obrigatório",
|
||||
"email_invalid": "Insira um e-mail válido",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "As mensagens selecionadas foram restauradas com sucesso para o servidor IMAP.",
|
||||
"failed": "Falha ao restaurar mensagens",
|
||||
"failedTitle": "Falha na restauração"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tempo",
|
||||
"since": "Desde",
|
||||
"before": "Antes de",
|
||||
"recent_range": "Intervalo recente (desde...)",
|
||||
"historical": "Intervalo histórico (antes de...)",
|
||||
"absolute_range": "Intervalo de datas absoluto",
|
||||
"last_day": "Último 1 dia",
|
||||
"last_days": "Últimos {{count}} dias",
|
||||
"last_months": "Últimos {{count}} meses",
|
||||
"over_years_ago": "Há {{count}} {{unit}}",
|
||||
"year": "ano",
|
||||
"years": "anos",
|
||||
"recent_prefix": "Recente:",
|
||||
"days_ago_to_now": "dias atrás até agora",
|
||||
"apply": "Aplicar",
|
||||
"start_date": "Data de início",
|
||||
"end_date": "Data de fim",
|
||||
"clear_filters": "Limpar filtros de tempo",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Etiquetas",
|
||||
"search_placeholder": "Pesquisar etiquetas...",
|
||||
"clear_all": "Limpar todas as etiquetas",
|
||||
"no_tags_found": "Nenhuma etiqueta encontrada"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Contas de e-mail",
|
||||
"search_placeholder": "Pesquisar contas de e-mail...",
|
||||
"clear_accounts": "Limpar contas selecionadas",
|
||||
"no_accounts_found": "Nenhuma conta encontrada"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Caixas de correio",
|
||||
"search_placeholder": "Pesquisar pastas",
|
||||
"clear_mailboxes": "Limpar pastas selecionadas",
|
||||
"select_account_first": "Selecione primeiro uma conta de e-mail",
|
||||
"no_mailbox_found": "Nenhuma pasta encontrada"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contatos",
|
||||
"label_with_count": "Contatos ({{count}})",
|
||||
"any": "Qualquer",
|
||||
"search_placeholder": "Pesquisar {{field}}...",
|
||||
"reset_all": "Redefinir todos os contatos",
|
||||
"no_contact_found": "Nenhum contato encontrado",
|
||||
"loading": "Carregando...",
|
||||
"showing_limit": "Mostrando apenas os primeiros 100 resultados • Total {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avançado",
|
||||
"title": "Filtros avançados",
|
||||
"reset": "Redefinir",
|
||||
"has_attachment": "Contém anexo",
|
||||
"attachment_name_label": "Nome do anexo",
|
||||
"attachment_name_placeholder": "Ex.: invoice.pdf",
|
||||
"message_size_label": "Tamanho da mensagem",
|
||||
"message_id_label": "Message-ID original",
|
||||
"message_id_description": "Pesquisar pelo Message-ID no cabeçalho do e-mail",
|
||||
"apply": "Aplicar filtros",
|
||||
"size_presets": {
|
||||
"any": "Qualquer tamanho",
|
||||
"tiny": "Muito pequeno (< 15 KB)",
|
||||
"small": "Pequeno (< 2 MB)",
|
||||
"medium": "Médio (2 – 10 MB)",
|
||||
"large": "Grande (10 – 20 MB)",
|
||||
"huge": "Muito grande (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Visualização",
|
||||
"menu_title": "Configurações de colunas"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Redefinir",
|
||||
"tooltip": "Limpar todos os filtros"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Pesquisar e-mails... (use \"aspas\" para correspondência exata)",
|
||||
"button": "Pesquisar",
|
||||
"recent_title": "Pesquisas recentes",
|
||||
"clear_history": "Limpar histórico",
|
||||
"no_history": "Nenhum histórico de pesquisa",
|
||||
"hint": "Escopo padrão: assunto, conteúdo e nomes dos anexos"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Любой",
|
||||
"tiny": "Очень маленький (<15 КБ)",
|
||||
"small": "Маленький (<2 МБ)",
|
||||
"medium": "Средний (<20 МБ)",
|
||||
"large": "Большой (≥20 МБ)",
|
||||
"medium": "Средний (2 - 10 МБ)",
|
||||
"large": "Большой (10 - 20 МБ)",
|
||||
"huge": "Огромный (≥20 MB)",
|
||||
"sort": "Сортировка",
|
||||
"sizeDescription": "Размер означает общий размер электронного письма, включая вложения.",
|
||||
"title": "Поиск",
|
||||
"searching": "Поиск, пожалуйста, подождите...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Выберите аккаунт",
|
||||
"role_required": "Выберите роль",
|
||||
"username_required": "Имя пользователя обязательно",
|
||||
"username_min": "Минимум 5 символов",
|
||||
"username_min": "Минимум 3 символов",
|
||||
"username_max": "Максимум 32 символа",
|
||||
"email_required": "Эл. почта обязательна",
|
||||
"email_invalid": "Введите корректный адрес эл. почты",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "Выбранные сообщения были успешно восстановлены на IMAP-сервере.",
|
||||
"failed": "Не удалось восстановить сообщения",
|
||||
"failedTitle": "Ошибка восстановления"
|
||||
},
|
||||
"time": {
|
||||
"label": "Время",
|
||||
"since": "С",
|
||||
"before": "До",
|
||||
"recent_range": "Недавний диапазон (с...)",
|
||||
"historical": "Исторический диапазон (до...)",
|
||||
"absolute_range": "Абсолютный диапазон дат",
|
||||
"last_day": "Последний 1 день",
|
||||
"last_days": "Последние {{count}} дней",
|
||||
"last_months": "Последние {{count}} месяцев",
|
||||
"over_years_ago": "{{count}} {{unit}} назад",
|
||||
"year": "год",
|
||||
"years": "лет",
|
||||
"recent_prefix": "Недавно:",
|
||||
"days_ago_to_now": "дней назад — по настоящее время",
|
||||
"apply": "Применить",
|
||||
"start_date": "Дата начала",
|
||||
"end_date": "Дата окончания",
|
||||
"clear_filters": "Очистить фильтр времени",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Теги",
|
||||
"search_placeholder": "Поиск тегов...",
|
||||
"clear_all": "Очистить все теги",
|
||||
"no_tags_found": "Теги не найдены"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Почтовые аккаунты",
|
||||
"search_placeholder": "Поиск почтовых аккаунтов...",
|
||||
"clear_accounts": "Очистить выбранные аккаунты",
|
||||
"no_accounts_found": "Аккаунты не найдены"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Почтовые папки",
|
||||
"search_placeholder": "Поиск папок",
|
||||
"clear_mailboxes": "Очистить выбранные папки",
|
||||
"select_account_first": "Сначала выберите почтовый аккаунт",
|
||||
"no_mailbox_found": "Папки не найдены"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Контакты",
|
||||
"label_with_count": "Контакты ({{count}})",
|
||||
"any": "Любые",
|
||||
"search_placeholder": "Поиск по {{field}}...",
|
||||
"reset_all": "Сбросить все контакты",
|
||||
"no_contact_found": "Контакты не найдены",
|
||||
"loading": "Загрузка...",
|
||||
"showing_limit": "Показаны первые 100 результатов • Всего {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Дополнительно",
|
||||
"title": "Расширенные фильтры",
|
||||
"reset": "Сброс",
|
||||
"has_attachment": "Есть вложение",
|
||||
"attachment_name_label": "Имя вложения",
|
||||
"attachment_name_placeholder": "Например: invoice.pdf",
|
||||
"message_size_label": "Размер письма",
|
||||
"message_id_label": "Исходный Message-ID",
|
||||
"message_id_description": "Поиск по Message-ID из заголовков письма",
|
||||
"apply": "Применить фильтры",
|
||||
"size_presets": {
|
||||
"any": "Любой размер",
|
||||
"tiny": "Очень маленький (< 15 КБ)",
|
||||
"small": "Маленький (< 2 МБ)",
|
||||
"medium": "Средний (2–10 МБ)",
|
||||
"large": "Большой (10–20 МБ)",
|
||||
"huge": "Очень большой (> 20 МБ)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Вид",
|
||||
"menu_title": "Настройки отображаемых столбцов"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Сброс",
|
||||
"tooltip": "Очистить все фильтры"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Поиск писем... (используйте \"кавычки\" для точного совпадения)",
|
||||
"button": "Поиск",
|
||||
"recent_title": "Недавние поиски",
|
||||
"clear_history": "Очистить историю",
|
||||
"no_history": "История поиска пуста",
|
||||
"hint": "По умолчанию поиск выполняется по теме, содержимому и именам вложений"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "Alla",
|
||||
"tiny": "Mycket liten (<15 KB)",
|
||||
"small": "Liten (<2 MB)",
|
||||
"medium": "Medelstor (<20 MB)",
|
||||
"large": "Stor (≥20 MB)",
|
||||
"medium": "Medelstor (2 - 10 MB)",
|
||||
"large": "Stor (10 - 20 MB)",
|
||||
"huge": "Mycket stor (≥20 MB)",
|
||||
"sort": "Sortera",
|
||||
"sizeDescription": "Storleken avser e-postens totala storlek, inklusive bilagor.",
|
||||
"title": "Sök",
|
||||
"searching": "Söker, vänligen vänta…",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "Välj ett konto",
|
||||
"role_required": "Välj en roll",
|
||||
"username_required": "Användarnamn krävs",
|
||||
"username_min": "Användarnamnet måste vara minst 5 tecken",
|
||||
"username_min": "Användarnamnet måste vara minst 3 tecken",
|
||||
"username_max": "Användarnamnet får inte överstiga 32 tecken",
|
||||
"email_required": "E-postadress krävs",
|
||||
"email_invalid": "Ange en giltig e-postadress",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "De valda meddelandena har återställts till IMAP-servern.",
|
||||
"failed": "Misslyckades med att återställa meddelanden",
|
||||
"failedTitle": "Återställning misslyckades"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tid",
|
||||
"since": "Sedan",
|
||||
"before": "Före",
|
||||
"recent_range": "Senaste intervall (sedan...)",
|
||||
"historical": "Historiskt intervall (före...)",
|
||||
"absolute_range": "Absolut datumintervall",
|
||||
"last_day": "Senaste 1 dagen",
|
||||
"last_days": "Senaste {{count}} dagarna",
|
||||
"last_months": "Senaste {{count}} månaderna",
|
||||
"over_years_ago": "För {{count}} {{unit}} sedan",
|
||||
"year": "år",
|
||||
"years": "år",
|
||||
"recent_prefix": "Senaste:",
|
||||
"days_ago_to_now": "dagar sedan till nu",
|
||||
"apply": "Använd",
|
||||
"start_date": "Startdatum",
|
||||
"end_date": "Slutdatum",
|
||||
"clear_filters": "Rensa tidsfilter",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Taggar",
|
||||
"search_placeholder": "Sök taggar...",
|
||||
"clear_all": "Rensa alla taggar",
|
||||
"no_tags_found": "Inga taggar hittades"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "E-postkonton",
|
||||
"search_placeholder": "Sök e-postkonton...",
|
||||
"clear_accounts": "Rensa valda konton",
|
||||
"no_accounts_found": "Inga konton hittades"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "E-postmappar",
|
||||
"search_placeholder": "Sök mappar",
|
||||
"clear_mailboxes": "Rensa valda mappar",
|
||||
"select_account_first": "Välj först ett e-postkonto",
|
||||
"no_mailbox_found": "Inga mappar hittades"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakter",
|
||||
"label_with_count": "Kontakter ({{count}})",
|
||||
"any": "Alla",
|
||||
"search_placeholder": "Sök {{field}}...",
|
||||
"reset_all": "Återställ alla kontakter",
|
||||
"no_contact_found": "Inga kontakter hittades",
|
||||
"loading": "Laddar...",
|
||||
"showing_limit": "Visar endast de första 100 resultaten • Totalt {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avancerat",
|
||||
"title": "Avancerade filter",
|
||||
"reset": "Återställ",
|
||||
"has_attachment": "Har bilaga",
|
||||
"attachment_name_label": "Bilagans namn",
|
||||
"attachment_name_placeholder": "t.ex. invoice.pdf",
|
||||
"message_size_label": "Meddelandestorlek",
|
||||
"message_id_label": "Ursprungligt Message-ID",
|
||||
"message_id_description": "Sök via Message-ID i e-posthuvudet",
|
||||
"apply": "Använd filter",
|
||||
"size_presets": {
|
||||
"any": "Valfri storlek",
|
||||
"tiny": "Mycket liten (< 15 KB)",
|
||||
"small": "Liten (< 2 MB)",
|
||||
"medium": "Mellan (2–10 MB)",
|
||||
"large": "Stor (10–20 MB)",
|
||||
"huge": "Mycket stor (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Vy",
|
||||
"menu_title": "Inställningar för kolumner"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Återställ",
|
||||
"tooltip": "Rensa alla filter"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Sök e-post... (använd \"citattecken\" för exakt matchning)",
|
||||
"button": "Sök",
|
||||
"recent_title": "Senaste sökningar",
|
||||
"clear_history": "Rensa historik",
|
||||
"no_history": "Ingen sökhistorik",
|
||||
"hint": "Standardomfattning: ämne, innehåll och bilagenamn"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "不限",
|
||||
"tiny": "很小(<15 KB)",
|
||||
"small": "小(<2 MB)",
|
||||
"medium": "中(<20 MB)",
|
||||
"large": "大(≥20 MB)",
|
||||
"medium": "中(2 - 10 MB)",
|
||||
"large": "大(10 - 20 MB)",
|
||||
"huge": "極大 (≥20 MB)",
|
||||
"sort": "排序",
|
||||
"sizeDescription": "大小指的是整封郵件的大小,包含附件。",
|
||||
"title": "搜尋",
|
||||
"searching": "正在搜尋,請稍候...",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "請選擇一個帳戶",
|
||||
"role_required": "請選擇一個角色",
|
||||
"username_required": "使用者名稱不能為空",
|
||||
"username_min": "使用者名稱長度至少為 5 個字元",
|
||||
"username_min": "使用者名稱長度至少為 3 個字元",
|
||||
"username_max": "使用者名稱長度不能超過 32 個字元",
|
||||
"email_required": "電子郵件地址不能為空",
|
||||
"email_invalid": "請輸入有效的電子郵件地址",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "選定的郵件已成功還原到 IMAP 伺服器。",
|
||||
"failed": "還原郵件失敗",
|
||||
"failedTitle": "還原失敗"
|
||||
},
|
||||
"time": {
|
||||
"label": "時間",
|
||||
"since": "自",
|
||||
"before": "早於",
|
||||
"recent_range": "近期範圍(自…)",
|
||||
"historical": "歷史範圍(早於…)",
|
||||
"absolute_range": "絕對日期範圍",
|
||||
"last_day": "最近 1 天",
|
||||
"last_days": "最近 {{count}} 天",
|
||||
"last_months": "最近 {{count}} 個月",
|
||||
"over_years_ago": "{{count}} {{unit}} 前",
|
||||
"year": "年",
|
||||
"years": "年",
|
||||
"recent_prefix": "最近:",
|
||||
"days_ago_to_now": "天前至今",
|
||||
"apply": "套用",
|
||||
"start_date": "開始日期",
|
||||
"end_date": "結束日期",
|
||||
"clear_filters": "清除時間篩選",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "標籤",
|
||||
"search_placeholder": "搜尋標籤…",
|
||||
"clear_all": "清除所有標籤",
|
||||
"no_tags_found": "未找到標籤"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "郵件帳戶",
|
||||
"search_placeholder": "搜尋郵件帳戶…",
|
||||
"clear_accounts": "清除已選帳戶",
|
||||
"no_accounts_found": "未找到帳戶"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "信箱資料夾",
|
||||
"search_placeholder": "搜尋信箱資料夾",
|
||||
"clear_mailboxes": "清除已選資料夾",
|
||||
"select_account_first": "請先選擇郵件帳戶",
|
||||
"no_mailbox_found": "未找到資料夾"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "聯絡人",
|
||||
"label_with_count": "聯絡人({{count}})",
|
||||
"any": "不限",
|
||||
"search_placeholder": "搜尋 {{field}}…",
|
||||
"reset_all": "重設所有聯絡人",
|
||||
"no_contact_found": "未找到聯絡人",
|
||||
"loading": "載入中…",
|
||||
"showing_limit": "僅顯示前 100 筆結果 • 共 {{total}} 筆"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "進階",
|
||||
"title": "進階篩選",
|
||||
"reset": "重設",
|
||||
"has_attachment": "包含附件",
|
||||
"attachment_name_label": "附件名稱",
|
||||
"attachment_name_placeholder": "例如:invoice.pdf",
|
||||
"message_size_label": "郵件大小",
|
||||
"message_id_label": "原始 Message-ID",
|
||||
"message_id_description": "依郵件標頭中的 Message-ID 搜尋",
|
||||
"apply": "套用篩選",
|
||||
"size_presets": {
|
||||
"any": "不限大小",
|
||||
"tiny": "極小(< 15 KB)",
|
||||
"small": "較小(< 2 MB)",
|
||||
"medium": "一般(2–10 MB)",
|
||||
"large": "較大(10–20 MB)",
|
||||
"huge": "極大(> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "檢視",
|
||||
"menu_title": "顯示欄位設定"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "重設",
|
||||
"tooltip": "清除所有篩選條件"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "搜尋郵件…(使用「雙引號」進行精確比對)",
|
||||
"button": "搜尋",
|
||||
"recent_title": "最近搜尋",
|
||||
"clear_history": "清除紀錄",
|
||||
"no_history": "尚無搜尋紀錄",
|
||||
"hint": "預設搜尋範圍:郵件標題、內容與附件名稱"
|
||||
}
|
||||
}
|
||||
@@ -412,8 +412,10 @@
|
||||
"any": "不限",
|
||||
"tiny": "很小(<15 KB)",
|
||||
"small": "小(<2 MB)",
|
||||
"medium": "中(<20 MB)",
|
||||
"large": "大(≥20 MB)",
|
||||
"medium": "中(2 - 10 MB)",
|
||||
"large": "大(10 - 20 MB)",
|
||||
"huge": "极大 (≥20 MB)",
|
||||
"sort": "排序",
|
||||
"sizeDescription": "大小指的是邮件整体大小,包含附件。",
|
||||
"title": "搜索",
|
||||
"searching": "搜索中,请稍候…",
|
||||
@@ -1272,7 +1274,7 @@
|
||||
"account_required": "请选择一个账户",
|
||||
"role_required": "请选择一个角色",
|
||||
"username_required": "用户名不能为空",
|
||||
"username_min": "用户名长度至少为 5 个字符",
|
||||
"username_min": "用户名长度至少为 3 个字符",
|
||||
"username_max": "用户名长度不能超过 32 个字符",
|
||||
"email_required": "邮箱地址不能为空",
|
||||
"email_invalid": "请输入有效的邮箱地址",
|
||||
@@ -1459,5 +1461,91 @@
|
||||
"successDesc": "选定的邮件已成功恢复到 IMAP 服务器。",
|
||||
"failed": "还原邮件失败",
|
||||
"failedTitle": "还原失败"
|
||||
},
|
||||
"time": {
|
||||
"label": "时间",
|
||||
"since": "自",
|
||||
"before": "早于",
|
||||
"recent_range": "近期范围 (自...)",
|
||||
"historical": "历史范围 (早于...)",
|
||||
"absolute_range": "绝对日期范围",
|
||||
"last_day": "最近 1 天",
|
||||
"last_days": "最近 {{count}} 天",
|
||||
"last_months": "最近 {{count}} 个月",
|
||||
"over_years_ago": "{{count}} {{unit}}前",
|
||||
"year": "年",
|
||||
"years": "年",
|
||||
"recent_prefix": "最近:",
|
||||
"days_ago_to_now": "天前至今",
|
||||
"apply": "应用",
|
||||
"start_date": "开始日期",
|
||||
"end_date": "结束日期",
|
||||
"clear_filters": "清除时间筛选",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "标签",
|
||||
"search_placeholder": "搜索标签...",
|
||||
"clear_all": "清除所有标签",
|
||||
"no_tags_found": "未找到标签"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "邮件账户",
|
||||
"search_placeholder": "搜索邮件账户...",
|
||||
"clear_accounts": "清除已选账户",
|
||||
"no_accounts_found": "未找到账户"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "邮箱文件夹",
|
||||
"search_placeholder": "搜索邮箱文件夹",
|
||||
"clear_mailboxes": "清除已选文件夹",
|
||||
"select_account_first": "请先选择邮件账户",
|
||||
"no_mailbox_found": "未找到文件夹"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "联系人",
|
||||
"label_with_count": "联系人 ({{count}})",
|
||||
"any": "不限",
|
||||
"search_placeholder": "搜索 {{field}}...",
|
||||
"reset_all": "重置所有联系人",
|
||||
"no_contact_found": "未找到联系人",
|
||||
"loading": "加载中...",
|
||||
"showing_limit": "仅显示前 100 条结果 • 总计 {{total}} 条"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "高级",
|
||||
"title": "高级筛选",
|
||||
"reset": "重置",
|
||||
"has_attachment": "包含附件",
|
||||
"attachment_name_label": "附件名称",
|
||||
"attachment_name_placeholder": "例如: invoice.pdf",
|
||||
"message_size_label": "邮件大小",
|
||||
"message_id_label": "原始消息 ID",
|
||||
"message_id_description": "通过邮件头中的 'Message-ID' 进行搜索",
|
||||
"apply": "应用筛选",
|
||||
"size_presets": {
|
||||
"any": "不限大小",
|
||||
"tiny": "极小 (< 15 KB)",
|
||||
"small": "较小 (< 2 MB)",
|
||||
"medium": "普通 (2 - 10 MB)",
|
||||
"large": "较大 (10 - 20 MB)",
|
||||
"huge": "极大 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "视图",
|
||||
"menu_title": "显示列设置"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "重置",
|
||||
"tooltip": "清除所有筛选条件"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "搜索邮件... (使用 \"双引号\" 进行精确匹配)",
|
||||
"button": "搜索",
|
||||
"recent_title": "最近搜索",
|
||||
"clear_history": "清空记录",
|
||||
"no_history": "暂无搜索记录",
|
||||
"hint": "默认搜索范围:邮件标题、正文及附件名称"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user