35 Commits
0.3.0 ... 0.3.2

Author SHA1 Message Date
rustmailer
7d150c2982 bump to 0.3.2 2026-01-20 01:25:53 +08:00
rustmailer
9b49005522 fix: "unknown" sender when importing PST #117 2026-01-20 01:22:06 +08:00
rustmailer
1f4e9f7b06 Update nosync-dialog.tsx 2026-01-20 01:21:42 +08:00
rustmailer
d0e4cac229 i18n 2026-01-20 00:53:09 +08:00
rustmailer
30a8d856c1 update 2026-01-19 22:53:11 +08:00
rustmailer
7240be8c31 feat: Separate Docker config file location and email data storage location #81 2026-01-19 22:53:03 +08:00
rustmailer
0d20a9676a feat(search-ui): optimize search UI 2026-01-19 01:52:07 +08:00
rustmailer
48f8092b5a Merge pull request #113 from ktdd/search-improvements-v1
Search improvements
2026-01-14 14:32:38 +08:00
rustmailer
a64351d409 Merge pull request #112 from ktdd/search-table-v1
Replaced email listing with a table with adjustable columns + other changes
2026-01-14 14:32:24 +08:00
rustmailer
a6dd1d19ff Update README.md 2026-01-14 14:01:05 +08:00
rustmailer
f82b20e2fd chore: Set minimum username length to 3 #106 2026-01-14 01:11:01 +08:00
rustmailer
9841038acb chore: Adjust dark mode brightness and light mode saturation #109 2026-01-14 01:04:10 +08:00
rustmailer
97c3db3bd2 chore: default to binding 0.0.0.0 and support binding IPv6 addresses. 2026-01-14 00:27:31 +08:00
rustmailer
82fb2a02bc Merge pull request #110 from op3/feat/support-listening-on-ipv6
Support listening on IPv6 addresses
2026-01-13 23:59:02 +08:00
rustmailer
c61977ce5c Merge pull request #107 from metlos/no-cap-on-sync-interval
Remove the maximum from the sync_interval_min.
2026-01-13 23:51:14 +08:00
rustmailer
106a08fb7e bump verison to 0.3.1 2026-01-13 23:34:46 +08:00
rustmailer
3419506c2e Update README.md 2026-01-13 23:34:17 +08:00
rustmailer
3b040d0cd6 feat: add support for Outlook PST file import #105 2026-01-13 23:32:18 +08:00
rustmailer
5d3c319a67 fix: skip invalid MBOX files during import 2026-01-13 23:31:45 +08:00
ktdd
ad2a43aa35 Search improvements 2026-01-13 12:48:55 +02:00
ktdd
884ae64fc5 Formatting 2026-01-12 12:22:52 +02:00
ktdd
254de35f0e Replaced email listing with a table with adjustable columns. 2026-01-11 18:15:03 +02:00
Oliver Papst
bc3eba5bf7 feat: change default bind address to :: for dual‑stack support
The socket bound to :: accepts both IPv6 and IPv4 (mapped) connections,
so this change enables IPv6 connectivity in addition to the existing
IPv4 behaviour.
2026-01-10 22:36:31 +01:00
Oliver Papst
4dc99b4a84 feat: Add IPv6 support for bichon_bind_ip configuration
Also try to parse the bind_ip address as std::net::Ipv6Addr to accept
both IPv4 and IPv6 addresses. The TcpListener of poem utilizes
ToSocketAddrs trait, which also supports IPv6.
2026-01-10 22:25:31 +01:00
Lukas Krejci
8ce8b9692d Remove the maximum from the sync_interval_min. 2026-01-09 01:35:58 +01:00
rustmailer
3fb761064d Update README.md 2026-01-08 10:52:30 +08:00
rustmailer
54a0a71c44 fix: Missing permission 'user:manage' #102 2026-01-07 23:01:25 +08:00
rustmailer
b490923e17 refactor(search): search filtering and sorting 2026-01-07 16:40:45 +08:00
rustmailer
4ee44daf0d Merge pull request #103 from ktdd/presets-and-sort
Updated presets and added a 'sort by' feature.
2026-01-07 15:03:17 +08:00
ktdd
7edd7c2e35 Updated presets and added a 'sort by' feature. 2026-01-06 12:33:09 +02:00
rustmailer
0c46432150 fix: Inline attachments are not counted as attachments and are not shown when searching for emails with attachments. 2026-01-06 16:28:55 +08:00
rustmailer
d334a23ca7 chore(ui): add attachment file type icon 2026-01-06 16:27:05 +08:00
rustmailer
1768c1a590 fix: Large empty space at the bottom of the screen #98 2026-01-06 14:36:01 +08:00
rustmailer
147f5b4f55 Update README.md 2026-01-05 22:34:24 +08:00
rustmailer
48312dc83e Update README.md 2026-01-05 22:21:53 +08:00
96 changed files with 5756 additions and 1046 deletions

476
Cargo.lock generated
View File

@@ -25,7 +25,18 @@ checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561"
dependencies = [
"aes-soft",
"aesni",
"cipher",
"cipher 0.2.5",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cpufeatures",
]
[[package]]
@@ -35,8 +46,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5278b5fabbb9bd46e24aa69b2fdea62c99088e0a950a9be40e3e0101298f88da"
dependencies = [
"aead",
"aes",
"cipher",
"aes 0.6.0",
"cipher 0.2.5",
"ctr",
"ghash",
"subtle",
@@ -48,7 +59,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072"
dependencies = [
"cipher",
"cipher 0.2.5",
"opaque-debug",
]
@@ -58,7 +69,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce"
dependencies = [
"cipher",
"cipher 0.2.5",
"opaque-debug",
]
@@ -170,6 +181,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arc-swap"
version = "1.7.1"
@@ -424,7 +444,7 @@ dependencies = [
[[package]]
name = "bichon"
version = "0.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"

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ pub struct AccountCreateRequest {
pub account_type: AccountType,
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>,
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
#[oai(validator(minimum(value = "10")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
@@ -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>,

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,10 @@ use std::{
time::Duration,
};
use crate::modules::message::tags::TagCount;
use crate::modules::{
indexer::envelope::extract_contacts,
message::{search::SortBy, tags::TagCount},
};
use crate::{
modules::{
account::migration::AccountModel,
@@ -57,7 +60,10 @@ use tantivy::{
AggregationCollector, Key,
},
collector::{Count, FacetCollector, TopDocs},
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
query::{
AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery,
TermQuery,
},
schema::{Facet, IndexRecordOption, Value},
store::{Compressor, ZstdCompressor},
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
@@ -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>>,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

6
web/pnpm-lock.yaml generated
View File

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

View File

@@ -21,6 +21,7 @@ import axiosInstance from "@/api/axiosInstance";
export interface MailboxData {
account_id: number;
attributes: { attr: string; extension: string | null }[];
delimiter: string | null;
exists: number;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -164,7 +164,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
<ScrollArea className='h-[13rem] w-full pr-4 -mr-4 py-1'>
<Form {...form}>
<form
id='nosync-account-form'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,24 +21,18 @@ import { Card, CardContent } from '@/components/ui/card';
import { FixedHeader } from '@/components/layout/fixed-header';
import { Main } from '@/components/layout/main';
import { useSearchMessages } from '@/hooks/use-search-messages';
import { SearchFormDialog } from './search-form';
import { EnvelopeListPagination } from '@/components/pagination';
import { MailList } from './mail-list';
import React from 'react';
import { EmailEnvelope } from '@/api';
import { Filter, SearchIcon } from 'lucide-react';
import { MailDisplayDrawer } from './mail-display-dialog';
import { EnvelopeDeleteDialog } from './delete-dialog';
import SearchProvider, { SearchDialogType } from './context';
import useDialogState from '@/hooks/use-dialog-state';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { EnvelopeTags } from './tag-facet';
import { EditTagsDialog } from './add-tag-dialog';
import { useTranslation } from 'react-i18next';
import Logo from '@/assets/logo.svg'
import { RestoreMessageDialog } from './restore-message-dialog';
import { MailListTable } from './mail-list-table';
import { SortingState } from '@tanstack/react-table';
export default function Search() {
const { t } = useTranslation()
@@ -47,20 +41,21 @@ export default function Search() {
const [toDelete, setToDelete] = React.useState<Map<number, Set<number>>>(new Map());
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
const {
emails,
total,
totalPages,
isLoading,
isFetching,
page,
pageSize,
setPage,
setPageSize,
onSubmit,
reset,
filter
setSortBy,
setSortOrder,
filter,
setFilter
} = useSearchMessages();
const handleSetPageSize = (pageSize: number) => {
@@ -68,12 +63,6 @@ export default function Search() {
setPageSize(pageSize)
}
const handleReset = () => {
reset();
setSelectedTags([]);
};
const handleTagToggle = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag)
@@ -86,45 +75,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>
</>
);
}
}

View File

@@ -0,0 +1,333 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { format, formatDistanceToNow } from "date-fns"
import { MessageSquareText, Paperclip } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox"
import { EmailEnvelope } from "@/api"
import { useSearchContext } from "./context"
import { MailBulkActions } from "./bulk-actions"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
import { ColumnDef } from "@tanstack/react-table"
import LongText from "@/components/long-text"
import { DataTableColumnHeader } from "./table/data-table-column-header"
import { SearchTable } from "./table/table"
import { DataTableRowActions } from "./table/data-table-row-actions"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { DataTableToolbar } from "./table/toolbar"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
interface MailListProps {
items: EmailEnvelope[]
isLoading: boolean
onEnvelopeChanged: (envelope: EmailEnvelope) => void
setSortBy: (sortBy: "DATE" | "SIZE") => void
setSortOrder: (value: "desc" | "asc") => void
}
export function MailListTable({
items,
isLoading,
onEnvelopeChanged,
setSortBy,
setSortOrder
}: MailListProps) {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
const { selected, setSelected } = useSearchContext()
const columns: ColumnDef<EmailEnvelope>[] = [
{
accessorKey: "id",
header: () => (
<Checkbox
checked={
totalSelected === items.length && items.length > 0
? true
: totalSelected > 0
? "indeterminate"
: false
}
onCheckedChange={handleToggleAll}
className="h-4 w-4"
/>
),
cell: ({ row }) => (
<Checkbox
checked={hasSelected(row.original.account_id, row.original.id)}
onCheckedChange={() => toggleSelected(row.original.account_id, row.original.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0"
/>
),
meta: { className: 'text-left text-sm' },
minSize: 25,
maxSize: 25,
},
{
accessorKey: "account_email",
header: t('search.account'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.account_email}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 156,
},
{
accessorKey: "mailbox_name",
header: t('search.mailbox'),
cell: ({ row }) => {
const mailbox = row.original.mailbox_name
const tags = row.original.tags ?? []
if (!mailbox) return null
const visible = tags.slice(0, 3)
const rest = tags.length - visible.length
const fullTags = tags.join(' · ')
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col leading-tight max-w-[130px] cursor-default">
<span className="text-xs truncate">
{mailbox}
</span>
{visible.length > 0 && (
<span className="text-[10px] text-primary/80 truncate">
{visible.join(' · ')}
{rest > 0 && ` · +${rest}`}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-xs"
>
<div className="text-xs font-medium mb-1">
{mailbox}
</div>
<div className="text-[11px] text-muted-foreground break-words">
{fullTags}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
},
meta: { className: 'text-left text-xs' },
minSize: 116,
maxSize: 116,
},
{
accessorKey: "from",
header: t('search.from'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.from}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 156,
},
{
accessorKey: "to",
header: t('search.to'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.to.join(", ")}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 150,
maxSize: 156,
},
{
accessorKey: "subject",
header: t('search.subject'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 450,
maxSize: 456,
},
{
id: "text_preview",
header: () => null,
cell: ({ row }) => {
const text = row.original.text
if (!text) return null
return (
<HoverCard openDelay={200} closeDelay={150}>
<HoverCardTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-primary transition"
onClick={(e) => e.stopPropagation()}
>
<MessageSquareText size={16} />
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
className="max-w-[520px] max-h-[420px] overflow-auto whitespace-pre-wrap text-xs leading-relaxed"
>
{text}
</HoverCardContent>
</HoverCard>
)
},
meta: { className: "text-center max-w-[80px]" },
minSize: 36,
maxSize: 36,
enableSorting: false,
},
{
id: "attachment_count",
header: () => <Paperclip size={16} />,
cell: ({ row }) => <span className='text-xs'>{(row.original.attachments ?? []).length}</span>,
meta: { className: 'text-left text-xs' },
minSize: 40,
maxSize: 40
},
{
accessorKey: 'size',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('search.size')} />
),
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
meta: { className: 'text-left text-xs' },
minSize: 100,
maxSize: 100,
},
{
accessorKey: 'date',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('search.date')} />
),
cell: ({ row }) => {
const date = new Date(row.original.date)
const title = format(date, 'yyyy-MM-dd HH:mm:ss')
return (
<Tooltip>
<TooltipTrigger asChild>
<span className='text-xs whitespace-nowrap'>
{formatDistanceToNow(date, { addSuffix: true, locale })}
</span>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
)
},
meta: { className: 'text-left text-xs' },
minSize: 100,
maxSize: 100,
},
{
id: 'actions',
header: t('users.columns.actions'),
cell: DataTableRowActions,
meta: { className: 'text-left text-xs' },
minSize: 50,
maxSize: 60,
},
]
const handleToggleAll = () => {
const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
if (total === items.length && items.length > 0) {
setSelected(new Map())
} else {
setSelected(prev => {
const next = new Map(prev)
for (const item of items) {
const set = new Set(next.get(item.account_id) || [])
set.add(item.id)
next.set(item.account_id, set)
}
return next
})
}
}
const toggleSelected = (accountId: number, mailId: number) => {
setSelected(prev => {
const next = new Map(prev)
const set = new Set(next.get(accountId) || [])
if (set.has(mailId)) {
set.delete(mailId)
if (set.size === 0) next.delete(accountId)
else next.set(accountId, set)
} else {
set.add(mailId)
next.set(accountId, set)
}
return next
})
}
const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
const hasSelected = (accountId: number, mailId: number) => selected.get(accountId)?.has(mailId) ?? false
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-2.5 w-16" />
</div>
))}
</div>
)
}
return (
<>
<SearchTable
data={items}
columns={columns}
onRowClick={(e, row) => {
const target = e.target as HTMLElement
if (target.closest('input[type="checkbox"], button')) return
onEnvelopeChanged(row.original)
}}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
>
{(table) => {
return <DataTableToolbar table={table} />
}}
</SearchTable>
{totalSelected > 0 && <MailBulkActions />}
</>
)
}

View File

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

View File

@@ -19,7 +19,7 @@
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore } from 'lucide-react';
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio } 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">

View File

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

View File

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

View File

@@ -1,499 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { DatePicker } from "@/components/date-picker";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ChevronDown, ChevronUp, Filter, RotateCcw } from "lucide-react";
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useState } from "react";
import { useForm } from "react-hook-form";
import { VirtualizedSelect } from "@/components/virtualized-select";
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
import { useNavigate } from "@tanstack/react-router";
import { list_mailboxes, MailboxData } from "@/api/mailbox/api";
import { useQuery } from "@tanstack/react-query";
import { useSearchContext } from "./context";
import { toast } from "@/hooks/use-toast";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useTranslation } from "react-i18next";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const getSearchFilterSchema = (t: (key: string) => string) => z.object({
text: z.string().optional().or(z.literal("")),
from: z
.string()
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
to: z
.string()
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
cc: z
.string()
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
bcc: z
.string()
.email({ message: t('validation.invalidEmail') })
.optional()
.or(z.literal("")),
has_attachment: z.boolean().optional(),
attachment_name: z.string().optional().or(z.literal("")),
since: z.date().optional(),
before: z.date().optional(),
account_id: z.number().optional().or(z.literal("")),
mailbox_id: z.number().optional().or(z.literal("")),
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large']).optional(),
message_id: z.string().optional().or(z.literal("")),
});
type SearchFilterForm = z.infer<ReturnType<typeof getSearchFilterSchema>>;
interface Props {
onSubmit: (values: Record<string, any>) => void,
isLoading: boolean,
reset: () => void,
open: boolean,
onOpenChange: (open: boolean) => void;
}
const isEmptyValue = (value: any): boolean => {
if (value === null || value === undefined) return true;
if (value === '') return true;
if (typeof value === 'string' && value.trim() === '') return true;
if (typeof value === 'number' && isNaN(value)) return true;
if (value === false) return true;
if (value === 0) return true;
return false;
};
const cleanEmpty = <T extends Record<string, any>>(obj: T): Partial<T> => {
return Object.fromEntries(
Object.entries(obj).filter(([_, value]) => !isEmptyValue(value))
) as Partial<T>;
};
function withSizePreset(values: Record<string, any>) {
const { size_preset, ...rest } = values;
switch (size_preset) {
case 'tiny':
return { ...rest, max_size: 15 * 1024 };
case 'small':
return { ...rest, max_size: 2 * 1024 * 1024 };
case 'medium':
return { ...rest, max_size: 20 * 1024 * 1024 };
case 'large':
return { ...rest, min_size: 20 * 1024 * 1024 };
default:
return rest;
}
}
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
const { t } = useTranslation()
const [showAdvanced, setShowAdvanced] = useState(false);
const [selectedAccountId, setSelectedAccountId] = useState<number | undefined>(undefined);
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
const { selectedTags } = useSearchContext();
const searchFilterSchema = getSearchFilterSchema(t)
const form = useForm<SearchFilterForm>({
resolver: zodResolver(searchFilterSchema),
defaultValues: {
text: "",
from: "",
to: "",
cc: "",
bcc: "",
attachment_name: "",
message_id: "",
size_preset: 'any',
has_attachment: false,
since: undefined,
before: undefined,
account_id: undefined,
mailbox_id: undefined,
},
mode: "onChange",
});
const navigate = useNavigate();
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
queryKey: ['search-account-mailboxes', `${selectedAccountId}`],
queryFn: () => list_mailboxes(selectedAccountId!, false),
enabled: !!selectedAccountId,
})
const mailboxesOptions = mailboxes?.map((mailbox: MailboxData) => ({
value: mailbox.id.toString(),
label: mailbox.name,
})) || [];
const handleSubmit = (values: Record<string, any>) => {
let cleaned = cleanEmpty(values);
const payload = withSizePreset(cleaned);
const finalPayload =
selectedTags.length > 0
? { ...payload, tags: selectedTags }
: payload;
if (Object.keys(finalPayload).length > 0) {
onSubmit(finalPayload);
} else {
toast({
title: t('search.pleaseSelectAtLeastOne'),
});
}
}
const handleClear = () => {
form.reset({
text: "",
from: "",
to: "",
cc: "",
bcc: "",
has_attachment: false,
attachment_name: "",
since: undefined,
before: undefined,
account_id: undefined,
mailbox_id: undefined,
size_preset: 'any',
message_id: "",
});
setSelectedAccountId(undefined);
}
return (<Sheet
open={open}
onOpenChange={onOpenChange}
>
<SheetContent className='w-full md:max-w-4xl mx-auto'>
<SheetHeader className="p-4 pb-3 border-b shrink-0">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2">
{t('search.searchArchivedEmails')}
</SheetTitle>
</div>
</SheetHeader>
<SheetDescription>
{t('search.fullTextMultiAccount')}
</SheetDescription>
<Form {...form}>
<form id="email-search-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField
control={form.control}
name="account_id"
render={({ field }) => (
<FormItem className="min-w-[180px]">
<div className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.account')}:</FormLabel>
<FormControl className="flex-1">
<VirtualizedSelect
options={accountsOptions}
isLoading={accountsIsLoading}
onSelectOption={(values) => {
const account_id = parseInt(values[0], 10);
setSelectedAccountId(account_id);
field.onChange(account_id);
}}
value={field.value?.toString() ?? ""}
placeholder={t('search.selectAccount')}
className="h-10 w-full"
noItemsComponent={
<div className="p-2">
<p className="text-xs">{t('search.noActiveEmailAccount')}</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: "/accounts" })}
>
{t('search.addEmailAccount')}
</Button>
</div>
}
/>
</FormControl>
</div>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mailbox_id"
render={({ field }) => (
<FormItem className="min-w-[180px]">
<div className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}:</FormLabel>
<FormControl className="flex-1">
<VirtualizedSelect
options={mailboxesOptions}
isLoading={isMailboxesLoading}
onSelectOption={(values) => field.onChange(parseInt(values[0], 10))}
value={field.value?.toString() ?? ""}
placeholder={t('search.selectMailbox')}
className="h-10 w-full"
noItemsComponent={
<div className="p-2">
<p className="text-xs">
{t('search.noMailboxSelectAccount')}
</p>
</div>
}
/>
</FormControl>
</div>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
</div>
<div className="flex flex-col sm:flex-row gap-3 items-stretch">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder={t('search.searchInSubjectBody')}
className="h-11 text-base"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 sm:ml-auto sm:self-center">
<Button type="submit" className="h-11 px-6" disabled={isLoading}>
{isLoading ? t('search.searchingButton') : <>{t('search.searchButton')}</>}
</Button>
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<Filter className="w-4 h-4 mr-1" />
{t('search.advanced')}
{showAdvanced ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</Button>
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => { handleClear(); reset(); }}
>
<RotateCcw className="w-4 h-4 mr-1" />
{t('search.clear')}
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm">
<FormField
control={form.control}
name="since"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.since')}</FormLabel>
<FormControl>
<DatePicker
placeholder={t('search.selectDate')}
selected={field.value}
onSelect={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="before"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.before')}</FormLabel>
<FormControl>
<DatePicker
placeholder={t('search.selectDate')}
selected={field.value}
onSelect={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="has_attachment"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<Checkbox
id="attach"
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="attach" className="cursor-pointer text-sm font-normal">
{t('search.hasAttachment')}
</FormLabel>
</FormItem>
)}
/>
</div>
</div>
<div>
{showAdvanced && <Accordion type="multiple" className="space-y-3">
{/* Sender & Recipients */}
<AccordionItem value="people">
<AccordionTrigger className="text-sm">
{t('search.sender')} / {t('search.recipient')}
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-2">
{(['from', 'to', 'cc', 'bcc'] as const).map((key) => (
<FormField
key={key}
control={form.control}
name={key}
render={({ field }) => (
<FormItem>
<FormLabel className="capitalize text-xs">
{key === 'from' ? t('search.from') : key === 'to' ? t('search.to') : key === 'cc' ? t('search.cc') : t('search.bcc')}:
</FormLabel>
<FormControl>
<Input
placeholder={`${key}@example.com`}
className="h-9"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="attachment">
<AccordionTrigger className="text-sm">
{t('search.attachmentsSize')}
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
<FormField
control={form.control}
name="attachment_name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">{t('search.attachmentName')}:</FormLabel>
<FormControl>
<Input placeholder="invoice.pdf" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="size_preset"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">
{t('search.size')}
</FormLabel>
<Select
value={field.value}
onValueChange={(value) => field.onChange(value)}
>
<FormControl>
<SelectTrigger className="h-9">
<SelectValue placeholder={t('search.any')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="any">{t('search.any')}</SelectItem>
<SelectItem value="tiny">{t('search.tiny')}</SelectItem>
<SelectItem value="small">{t('search.small')}</SelectItem>
<SelectItem value="medium">{t('search.medium')}</SelectItem>
<SelectItem value="large">{t('search.large')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
<FormDescription className="text-xs">
{t('search.sizeDescription')}
</FormDescription>
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="ids">
<AccordionTrigger className="text-sm">
{t('search.messageId')}
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
<FormField
control={form.control}
name="message_id"
render={({ field }) => (
<FormItem className="col-span-full">
<FormControl>
<Input placeholder="<abc123@example.com>" className="h-9" {...field} />
</FormControl>
<FormDescription className="text-xs">
{t('search.originalMessageIdHeader')}
</FormDescription>
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>}
</div>
</form>
</Form>
</SheetContent>
</Sheet>);
}

View File

@@ -0,0 +1,83 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
} from '@radix-ui/react-icons'
import { Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
const { t } = useTranslation()
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className=' h-8 data-[state=open]:bg-accent'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ml-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ml-2 h-4 w-4' />
) : (
<CaretSortIcon className='ml-2 h-4 w-4' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.asc')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.desc')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -0,0 +1,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>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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": "نطاق البحث الافتراضي: العنوان، المحتوى، وأسماء المرفقات"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 le-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à aujourdhui",
"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 dabord 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 dorigine",
"message_id_description": "Recherche via len-tête Message-ID de le-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 lhistorique",
"no_history": "Aucun historique de recherche",
"hint": "Portée par défaut : objet, contenu et noms des pièces jointes"
}
}

View File

@@ -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 dellemail, 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 nellintestazione 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"
}
}

View File

@@ -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": "検索対象:件名・本文・添付ファイル名"
}
}

View File

@@ -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": "기본 검색 범위: 제목, 본문 및 첨부파일 이름"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "Средний (210 МБ)",
"large": "Большой (1020 МБ)",
"huge": "Очень большой (> 20 МБ)"
}
},
"search_view": {
"button_label": "Вид",
"menu_title": "Настройки отображаемых столбцов"
},
"search_reset": {
"label": "Сброс",
"tooltip": "Очистить все фильтры"
},
"search_input": {
"placeholder": "Поиск писем... (используйте \"кавычки\" для точного совпадения)",
"button": "Поиск",
"recent_title": "Недавние поиски",
"clear_history": "Очистить историю",
"no_history": "История поиска пуста",
"hint": "По умолчанию поиск выполняется по теме, содержимому и именам вложений"
}
}

View File

@@ -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 (210 MB)",
"large": "Stor (1020 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"
}
}

View File

@@ -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": "一般210 MB",
"large": "較大1020 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": "預設搜尋範圍:郵件標題、內容與附件名稱"
}
}

View File

@@ -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": "默认搜索范围:邮件标题、正文及附件名称"
}
}