diff --git a/Cargo.lock b/Cargo.lock index 0a833cc..4629da6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,18 +25,7 @@ checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561" dependencies = [ "aes-soft", "aesni", - "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 0.2.17", + "cipher", ] [[package]] @@ -46,8 +35,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5278b5fabbb9bd46e24aa69b2fdea62c99088e0a950a9be40e3e0101298f88da" dependencies = [ "aead", - "aes 0.6.0", - "cipher 0.2.5", + "aes", + "cipher", "ctr", "ghash", "subtle", @@ -59,7 +48,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072" dependencies = [ - "cipher 0.2.5", + "cipher", "opaque-debug", ] @@ -69,7 +58,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce" dependencies = [ - "cipher 0.2.5", + "cipher", "opaque-debug", ] @@ -181,15 +170,6 @@ 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.9.0" @@ -445,6 +425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -508,7 +489,7 @@ dependencies = [ "itertools", "itoa", "lettre", - "lru", + "lru 0.17.0", "mail-parser", "mail-send", "memmap2", @@ -524,7 +505,7 @@ dependencies = [ "poem", "poem-derive", "poem-openapi", - "rand 0.10.0", + "rand 0.10.1", "rcgen", "regex", "reqwest", @@ -546,6 +527,7 @@ dependencies = [ "tokio-io-timeout", "tokio-rustls", "tokio-socks", + "tokio-util", "toml", "tracing", "tracing-appender", @@ -592,7 +574,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq 0.4.2", + "constant_time_eq", "cpufeatures 0.3.0", ] @@ -715,15 +697,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c53ba0f290bfc610084c05582d9c5d421662128fc69f4bf236707af6fd321b9" -[[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 = "camino" version = "1.2.1" @@ -819,16 +792,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" version = "4.6.0" @@ -904,6 +867,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes 1.11.1", + "memchr", +] + [[package]] name = "compare" version = "0.0.6" @@ -967,12 +940,6 @@ 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 = "constant_time_eq" version = "0.4.2" @@ -988,7 +955,7 @@ dependencies = [ "aes-gcm", "base64 0.13.1", "hkdf", - "hmac 0.10.1", + "hmac", "percent-encoding", "rand 0.8.5", "sha2 0.9.9", @@ -1036,21 +1003,6 @@ 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" @@ -1142,7 +1094,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb4a30d54f7443bf3d6191dcd486aca19e67cb3c49fa7a06a319966346707e7f" dependencies = [ - "cipher 0.2.5", + "cipher", ] [[package]] @@ -1237,12 +1189,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" -[[package]] -name = "deflate64" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" - [[package]] name = "der-parser" version = "10.0.0" @@ -1267,17 +1213,6 @@ 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.117", -] - [[package]] name = "derive_more" version = "2.0.1" @@ -1328,7 +1263,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common", - "subtle", ] [[package]] @@ -1528,9 +1462,9 @@ checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fjall" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf46551c9abc5fb0e0d540da36c875197285af2a29833892d7d3434b8617343" +checksum = "b62b25b4d815ae178d7d9e4aa32ee59f072efd5431c736abede1e6ee13c8c453" dependencies = [ "byteorder-lite", "byteview", @@ -1550,7 +1484,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", - "libz-rs-sys", "miniz_oxide", ] @@ -1922,6 +1855,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashify" version = "0.2.7" @@ -1988,6 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a62d7684f766b0f96344be88c023f9b6650039aea09d526b4974cce302eb61b1" dependencies = [ "async-trait", + "aws-lc-rs", "bitflags", "bytes 1.11.1", "cfg-if", @@ -2042,7 +1987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51ab2f639c231793c5f6114bdb9bbe50a7dbbfcd7c7c6bd8475dec2d991e964f" dependencies = [ "digest 0.9.0", - "hmac 0.10.1", + "hmac", ] [[package]] @@ -2055,15 +2000,6 @@ 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 = "hostname" version = "0.4.2" @@ -2435,15 +2371,6 @@ 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" @@ -2552,6 +2479,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.17", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2623,12 +2599,6 @@ 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.184" @@ -2655,15 +2625,6 @@ 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" @@ -2721,6 +2682,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0b564323a0fb6d54b864f625ae139de9612e27edb944dda37c109f05aac531" +dependencies = [ + "hashbrown 0.17.0", +] + [[package]] name = "lru-cache" version = "0.1.2" @@ -2738,9 +2708,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lsm-tree" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38ed727e0965f218f4e419231b9ecacc1db8fb5066cf1df0d2bafeef88459d4" +checksum = "e447ac67ff6aef4ec07fc19e507b219336cbba90a697c0dbeb1bf51b91536b67" dependencies = [ "byteorder-lite", "bytes 1.11.1", @@ -2768,31 +2738,19 @@ dependencies = [ "twox-hash", ] -[[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 = "mail-auth" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b7da45f78cc525d3750b623c967ae21c0cd28b2e6a9a2ee4b536a7cce3b21ce" +checksum = "9f026538d37471da028cd5c6a907777c3de2d84e2c71b2b47ae301a10977c5e6" dependencies = [ - "ahash", + "aws-lc-rs", "flate2", "hashify", "hickory-resolver", "mail-builder", "mail-parser", - "quick-xml 0.38.4", - "quick_cache", - "ring", + "quick-xml 0.39.2", "rustls-pki-types", "serde", "serde_json", @@ -2821,9 +2779,9 @@ dependencies = [ [[package]] name = "mail-send" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "114a4e27f3cfaf8918783e8fa4149b820c813b1bedc7755e20e12eff4518331e" +checksum = "d06f18f6ad15354b83645a05466570ced7bf770a17b138742e705d54407d83ba" dependencies = [ "base64 0.22.1", "gethostname", @@ -2833,10 +2791,10 @@ dependencies = [ "rand 0.9.2", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "smtp-proto", "tokio", "tokio-rustls", - "webpki-roots", ] [[package]] @@ -3313,9 +3271,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" dependencies = [ "cc", "libc", @@ -3381,16 +3339,6 @@ dependencies = [ "windows-link", ] -[[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 = "pem" version = "3.0.6" @@ -3660,12 +3608,6 @@ 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" @@ -3753,9 +3695,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] @@ -3766,10 +3708,8 @@ 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]] @@ -3884,9 +3824,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.1", @@ -4131,7 +4071,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -4194,6 +4134,15 @@ dependencies = [ "semver 0.9.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.28", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -4218,9 +4167,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "log", @@ -4232,6 +4181,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -4251,6 +4212,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.8" @@ -4260,7 +4248,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4561,6 +4549,22 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.1" @@ -4710,7 +4714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" dependencies = [ "discard", - "rustc_version", + "rustc_version 0.2.3", "stdweb-derive", "stdweb-internal-macros", "stdweb-internal-runtime", @@ -4909,7 +4913,7 @@ dependencies = [ "itertools", "levenshtein_automata", "log", - "lru", + "lru 0.16.3", "lz4_flex", "measure_time", "memmap2", @@ -5214,9 +5218,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ "bytes 1.11.1", "libc", @@ -5285,9 +5289,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes 1.11.1", "futures-core", @@ -5546,6 +5550,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -5646,6 +5656,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -5940,6 +5956,15 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -6560,20 +6585,6 @@ 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.117", -] [[package]] name = "zerotrie" @@ -6610,55 +6621,23 @@ dependencies = [ [[package]] name = "zip" -version = "6.0.0" +version = "8.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" dependencies = [ - "aes 0.8.4", - "arbitrary", - "bzip2", - "constant_time_eq 0.3.1", "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.47", - "zeroize", - "zopfli", - "zstd", + "typed-path", ] -[[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" diff --git a/Cargo.toml b/Cargo.toml index ec35ff6..20e82dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,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.149" -tokio = { version = "1.51.0", features = ["full"] } +tokio = { version = "1.52.0", features = ["full"] } tracing = "0.1.44" tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } @@ -79,19 +79,19 @@ oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] } url = { version = "2.5.8", features = ["serde"] } sysinfo = "0.38.4" num_cpus = "1.17.0" -rand = "0.10.0" +rand = "0.10.1" encoding_rs = "0.8.35" async-imap = { version = "0.11.2", default-features = false, features = [ "runtime-tokio", "compress", ] } webpki-roots = "1.0.6" -rustls = { version = "0.23.37", default-features = false, features = ["ring"] } +rustls = { version = "0.23.38", default-features = false, features = ["ring"] } rustls-pki-types = "1.14.0" tokio-io-timeout = "1.2.1" semver = "1.0.28" governor = "0.10.4" -lru = "0.16.3" +lru = "0.17.0" mime_guess = "2.0.5" hex = "0.4.3" time = { version = "0.3.47", features = [ @@ -105,7 +105,7 @@ autoconfig = "0.4.0" urlencoding = "2.1.3" dashmap = "6.1.0" # Statically links OpenSSL by compiling from source, avoiding system library dependencies -openssl-sys = { version = "0.9.112", optional = true, features = ["vendored"] } +openssl-sys = { version = "0.9.113", optional = true, features = ["vendored"] } gethostname = "1.1.0" itoa = "1.0.18" html2text = "0.16.7" @@ -117,14 +117,15 @@ memmap2 = "0.9.10" outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" } compressed-rtf = "1.0.1" codepage-strings = "1.0.2" -mail-send = "0.5.2" +mail-send = "0.6.0" rcgen = "0.14.7" rustls-pemfile = "2.2.0" blake3 = "1.8.4" uuid = { version = "1.23.0", features = ["v4", "serde"] } -fjall = { version = "3.1.3", features = ["lz4", "metrics", "bytes_1"] } +fjall = { version = "3.1.4", features = ["lz4", "metrics", "bytes_1"] } tantivy = { version = "0.26.0", features = ["zstd-compression"] } tracing-log = "0.2.0" +tokio-util = "0.7.18" [dev-dependencies] #bincode = "1.3.3" #secret-lib = "1.0.0" diff --git a/src/modules/account/dispatcher.rs b/src/modules/account/dispatcher.rs deleted file mode 100644 index 7d98710..0000000 --- a/src/modules/account/dispatcher.rs +++ /dev/null @@ -1,60 +0,0 @@ -// -// Copyright (c) 2025-2026 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 . - - -use crate::modules::account::state::AccountRunningState; -use std::sync::LazyLock; -use tokio::sync::mpsc; -use tracing::error; - -pub static STATUS_DISPATCHER: LazyLock = LazyLock::new(ErrorDispatcher::new); - -pub struct ErrorDispatcher { - channel: mpsc::Sender<(u64, String)>, -} - -impl ErrorDispatcher { - pub fn new() -> Self { - let (tx, mut rx) = mpsc::channel::<(u64, String)>(100); - - tokio::spawn(async move { - while let Some((account_id, error)) = rx.recv().await { - match AccountRunningState::append_error_message(account_id, error).await { - Ok(()) => {} - Err(error) => { - error!( - "Failed to append error for account: {}. Error: {:#?}", - &account_id, error - ); - } - } - } - }); - - ErrorDispatcher { channel: tx } - } - - pub async fn append_error(&self, account_id: u64, error: String) { - if let Err(e) = self.channel.send((account_id, error.clone())).await { - error!( - "Failed to dispatch status update for account: {}, Error: {}. Channel error: {:?}", - &account_id, error, e - ); - } - } -} diff --git a/src/modules/account/migration.rs b/src/modules/account/migration.rs index d7ca695..b9e8ba7 100644 --- a/src/modules/account/migration.rs +++ b/src/modules/account/migration.rs @@ -29,8 +29,8 @@ use crate::{ modules::{ account::{ entity::ImapConfig, + state::DownloadState, since::{DateSince, RelativeDate}, - state::AccountRunningState, }, cache::imap::mailbox::MailBox, database::{list_all_impl, secondary_find_impl, with_transaction}, @@ -46,7 +46,7 @@ use crate::modules::account::payload::AccountCreateRequest; use crate::modules::account::payload::AccountUpdateRequest; use crate::modules::account::payload::MinimalAccount; use crate::modules::cache::imap::task::SYNC_TASKS; -use crate::modules::context::controller::SYNC_CONTROLLER; +use crate::modules::context::controller::DOWNLOAD_CONTROLLER; use crate::modules::database::count_by_unique_secondary_key_impl; use crate::modules::database::delete_impl; use crate::modules::database::manager::DB_MANAGER; @@ -305,7 +305,7 @@ impl AccountV4 { .await?; if matches!(cloned.account_type, AccountType::IMAP) { - SYNC_CONTROLLER + DOWNLOAD_CONTROLLER .trigger_start(cloned.id, cloned.email.clone()) .await; } @@ -354,7 +354,7 @@ impl AccountV4 { async fn cleanup_account_resources_sequential(account: &AccountModel) -> BichonResult<()> { if matches!(account.account_type, AccountType::IMAP) { SYNC_TASKS.stop(account.id).await?; - AccountRunningState::delete(account.id).await?; + DownloadState::delete(account.id).await?; } OAuth2AccessToken::try_delete(account.id).await?; UserModel::cleanup_account(account.id).await?; @@ -365,16 +365,16 @@ impl AccountV4 { Ok(()) } - pub async fn update_sync_folders( + pub async fn update_download_folders( account_id: u64, - sync_folders: Vec, + download_folders: Vec, ) -> BichonResult<()> { update_impl(DB_MANAGER.meta_db(), move |rw| { rw.get().secondary::(AccountV4Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? - .ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound)) + .ok_or_else(|| raise_error!(format!("When trying to update account download folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound)) }, |current|{ let mut updated = current.clone(); - updated.sync_folders = Some(sync_folders); + updated.sync_folders = Some(download_folders); Ok(updated) }).await?; Ok(()) diff --git a/src/modules/account/mod.rs b/src/modules/account/mod.rs index f70527c..4dad257 100644 --- a/src/modules/account/mod.rs +++ b/src/modules/account/mod.rs @@ -16,11 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub mod dispatcher; pub mod entity; pub mod grant; pub mod migration; pub mod payload; -pub mod since; pub mod state; +pub mod since; +pub mod old_state; pub mod view; diff --git a/src/modules/account/old_state.rs b/src/modules/account/old_state.rs new file mode 100644 index 0000000..fa63f76 --- /dev/null +++ b/src/modules/account/old_state.rs @@ -0,0 +1,258 @@ +// +// Copyright (c) 2025-2026 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 . + +// use crate::{ +// modules::{ +// database::{async_find_impl, delete_impl, manager::DB_MANAGER, update_impl, upsert_impl}, +// error::{code::ErrorCode, BichonResult}, +// }, +// raise_error, utc_now, +// }; +use native_db::*; +use native_model::{native_model, Model}; +use poem_openapi::Object; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +pub struct MailboxBatchProgress { + pub total_batches: u32, + pub current_batch: u32, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +#[native_model(id = 2, version = 1)] +#[native_db] +pub struct AccountRunningState { + #[primary_key] + pub account_id: u64, + pub last_incremental_sync_start: i64, + pub last_incremental_sync_end: Option, + pub errors: Vec, + pub is_initial_sync_completed: bool, + pub progress: Option>, + pub initial_sync_start_time: Option, + pub initial_sync_end_time: Option, + pub initial_sync_failed_time: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +pub struct AccountError { + pub error: String, + pub at: i64, +} + +// impl AccountRunningState { +// pub async fn add(account_id: u64) -> BichonResult<()> { +// let info = AccountRunningState { +// account_id, +// last_incremental_sync_start: 0, +// last_incremental_sync_end: None, +// errors: vec![], +// is_initial_sync_completed: false, +// progress: None, +// initial_sync_start_time: Some(utc_now!()), +// initial_sync_end_time: None, +// initial_sync_failed_time: None, +// }; +// upsert_impl(DB_MANAGER.envelope_db(), info).await +// } + +// pub async fn get(account_id: u64) -> BichonResult> { +// async_find_impl(DB_MANAGER.envelope_db(), account_id).await +// } + +// async fn update_account_running_state( +// account_id: u64, +// updater: impl FnOnce(&AccountRunningState) -> BichonResult + Send + 'static, +// ) -> BichonResult<()> { +// if Self::get(account_id).await?.is_some() { +// update_impl( +// DB_MANAGER.envelope_db(), +// move |rw| { +// rw.get() +// .primary::(account_id) +// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? +// .ok_or_else(|| { +// raise_error!( +// format!("Cannot find sync info of account={}", account_id), +// ErrorCode::ResourceNotFound +// ) +// }) +// }, +// updater, +// ) +// .await?; +// } +// Ok(()) +// } + +// pub async fn delete(account_id: u64) -> BichonResult<()> { +// if Self::get(account_id).await?.is_none() { +// return Ok(()); +// } + +// delete_impl(DB_MANAGER.envelope_db(), move |rw| { +// rw.get() +// .primary::(account_id) +// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? +// .ok_or_else(|| { +// raise_error!( +// format!( +// "AccountRunningState '{}' not found during deletion process.", +// account_id +// ), +// ErrorCode::ResourceNotFound +// ) +// }) +// }) +// .await +// } + +// // pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> { +// // Self::update_account_running_state(account_id, move |current| { +// // let mut updated = current.clone(); +// // updated.initial_sync_start_time = Some(utc_now!()); +// // Ok(updated) +// // }) +// // .await +// // } + +// pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> { +// Self::update_account_running_state(account_id, move |current| { +// let mut updated = current.clone(); +// updated.is_initial_sync_completed = true; +// updated.initial_sync_end_time = Some(utc_now!()); +// Ok(updated) +// }) +// .await +// } + +// pub async fn set_initial_sync_failed(account_id: u64) -> BichonResult<()> { +// Self::update_account_running_state(account_id, move |current| { +// let mut updated = current.clone(); +// updated.initial_sync_failed_time = Some(utc_now!()); +// Ok(updated) +// }) +// .await +// } + +// pub async fn set_current_sync_batch_number( +// account_id: u64, +// syncing_folder: String, +// batch_number: u32, +// ) -> BichonResult<()> { +// Self::update_account_running_state(account_id, move |current| { +// let mut updated = current.clone(); +// let mut progress_map = updated.progress.clone().unwrap_or_default(); +// let entry = +// progress_map +// .entry(syncing_folder.to_string()) +// .or_insert(MailboxBatchProgress { +// total_batches: 0, +// current_batch: 0, +// }); +// entry.current_batch = batch_number; +// updated.progress = Some(progress_map); +// Ok(updated) +// }) +// .await +// } + +// pub async fn set_folder_initial_sync_completed( +// account_id: u64, +// syncing_folder: String, +// ) -> BichonResult<()> { +// Self::update_account_running_state(account_id, move |current| { +// let mut updated = current.clone(); +// let mut progress_map = updated.progress.clone().unwrap_or_default(); +// let entry = +// progress_map +// .entry(syncing_folder.to_string()) +// .or_insert(MailboxBatchProgress { +// total_batches: 0, +// current_batch: 0, +// }); +// entry.current_batch = entry.total_batches; +// updated.progress = Some(progress_map); +// Ok(updated) +// }) +// .await +// } + +// pub async fn set_initial_current_syncing_folder( +// account_id: u64, +// current_syncing_folder: String, +// total_sync_batches: u32, +// ) -> BichonResult<()> { +// Self::update_account_running_state(account_id, move |current| { +// let mut updated = current.clone(); +// let mut progress_map = updated.progress.clone().unwrap_or_default(); +// progress_map.insert( +// current_syncing_folder.clone(), +// MailboxBatchProgress { +// total_batches: total_sync_batches, +// current_batch: 0, +// }, +// ); +// updated.progress = Some(progress_map); +// Ok(updated) +// }) +// .await +// } + +// pub async fn set_incremental_sync_start(account_id: u64) -> BichonResult<()> { +// Self::update_account_running_state(account_id, move |current| { +// let mut updated = current.clone(); +// updated.last_incremental_sync_start = utc_now!(); +// updated.last_incremental_sync_end = None; +// Ok(updated) +// }) +// .await +// } + +// pub async fn set_incremental_sync_end(account_id: u64) -> BichonResult<()> { +// Self::update_account_running_state(account_id, move |current| { +// let mut updated = current.clone(); +// updated.last_incremental_sync_end = Some(utc_now!()); +// Ok(updated) +// }) +// .await +// } + +// pub async fn append_error_message(account_id: u64, error: String) -> BichonResult<()> { +// Self::update_account_running_state(account_id, move |current| { +// let mut updated = current.clone(); +// updated.append_error_log(error); +// Ok(updated) +// }) +// .await +// } + +// pub fn append_error_log(&mut self, error: String) { +// let new_error = AccountError { +// error, +// at: utc_now!(), +// }; + +// self.errors.push(new_error); +// if self.errors.len() > ERROR_COUNT_PER_ACCOUNT { +// self.errors.remove(0); +// } +// } +// } diff --git a/src/modules/account/state.rs b/src/modules/account/state.rs index e3d63ef..f37d699 100644 --- a/src/modules/account/state.rs +++ b/src/modules/account/state.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::{ modules::{ database::{async_find_impl, delete_impl, manager::DB_MANAGER, update_impl, upsert_impl}, @@ -26,32 +25,68 @@ use crate::{ }; use native_db::*; use native_model::{native_model, Model}; -use poem_openapi::Object; +use poem_openapi::{Enum, Object}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -const ERROR_COUNT_PER_ACCOUNT: usize = 30; +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)] +pub enum DownloadStatus { + Running, + Success, + Failed, + #[default] + Cancelled, +} -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] -pub struct MailboxBatchProgress { - pub total_batches: u32, - pub current_batch: u32, +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)] +pub enum TriggerType { + Manual, + #[default] + Scheduled, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)] +pub enum FolderStatus { + #[default] + Pending, + Downloading, + Success, + Failed, + Cancelled, } #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] -#[native_model(id = 2, version = 1)] +pub struct FolderProgress { + pub folder_name: String, + pub planned: u64, + pub current: u64, + pub status: FolderStatus, + pub message: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +pub struct DownloadSession { + pub start_time: i64, + pub end_time: Option, + pub status: DownloadStatus, + pub message: Option, + pub trigger: TriggerType, + pub folder_details: BTreeMap, + pub current_folder: Option, + pub errors: Vec, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +#[native_model(id = 3, version = 1)] #[native_db] -pub struct AccountRunningState { +pub struct DownloadState { #[primary_key] pub account_id: u64, - pub last_incremental_sync_start: i64, - pub last_incremental_sync_end: Option, - pub errors: Vec, - pub is_initial_sync_completed: bool, - pub progress: Option>, - pub initial_sync_start_time: Option, - pub initial_sync_end_time: Option, - pub initial_sync_failed_time: Option, + pub active_session: Option, + pub history: Vec, + pub last_trigger_at: i64, + pub last_finished_at: Option, + pub global_errors: Vec, } #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] @@ -60,46 +95,169 @@ pub struct AccountError { pub at: i64, } -impl AccountRunningState { - pub async fn add(account_id: u64) -> BichonResult<()> { - let info = AccountRunningState { +impl DownloadState { + pub async fn init(account_id: u64) -> BichonResult<()> { + let state = DownloadState { account_id, - last_incremental_sync_start: 0, - last_incremental_sync_end: None, - errors: vec![], - is_initial_sync_completed: false, - progress: None, - initial_sync_start_time: Some(utc_now!()), - initial_sync_end_time: None, - initial_sync_failed_time: None, + ..Default::default() }; - upsert_impl(DB_MANAGER.envelope_db(), info).await + upsert_impl(DB_MANAGER.envelope_db(), state).await } - pub async fn get(account_id: u64) -> BichonResult> { + pub async fn get(account_id: u64) -> BichonResult> { async_find_impl(DB_MANAGER.envelope_db(), account_id).await } - async fn update_account_running_state( + pub async fn start_new_session(account_id: u64, trigger: TriggerType) -> BichonResult<()> { + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + updated.last_trigger_at = utc_now!(); + + if let Some(old_session) = updated.active_session.take() { + updated.history.push(old_session); + if updated.history.len() > 30 { + updated.history.remove(0); + } + } + + let new_session = DownloadSession { + start_time: utc_now!(), + status: DownloadStatus::Running, + trigger, + ..Default::default() + }; + + updated.active_session = Some(new_session); + Ok(updated) + }) + .await + } + + pub async fn update_session_status( account_id: u64, - updater: impl FnOnce(&AccountRunningState) -> BichonResult + Send + 'static, + status: DownloadStatus, + message: Option, ) -> BichonResult<()> { - update_impl( - DB_MANAGER.envelope_db(), - move |rw| { - rw.get() - .primary::(account_id) - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? - .ok_or_else(|| { - raise_error!( - format!("Cannot find sync info of account={}", account_id), - ErrorCode::ResourceNotFound - ) - }) - }, - updater, - ) - .await?; + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + if let Some(mut session) = updated.active_session.take() { + session.status = status.clone(); + if message.is_some() { + session.message = message; + } + if status == DownloadStatus::Running { + updated.active_session = Some(session); + } else { + let now = utc_now!(); + session.end_time = Some(now); + updated.last_finished_at = Some(now); + updated.history.push(session); + let to_remove = updated.history.len().saturating_sub(10); + if to_remove > 0 { + updated.history.drain(0..to_remove); + } + } + } + Ok(updated) + }) + .await + } + + pub async fn update_folder_progress( + account_id: u64, + folder_name: String, + planned: u64, + current: u64, + status: FolderStatus, + message: Option, + ) -> BichonResult<()> { + Self::update_state(account_id, move |state| { + let mut updated = state.clone(); + if let Some(ref mut session) = updated.active_session { + session.current_folder = Some(folder_name.clone()); + + let progress = + session + .folder_details + .entry(folder_name.clone()) + .or_insert(FolderProgress { + folder_name, + ..Default::default() + }); + + progress.planned = planned; + progress.current = current; + progress.status = status; + progress.message = message; + } + Ok(updated) + }) + .await + } + + pub async fn init_folder_details(account_id: u64, folders: Vec) -> BichonResult<()> { + Self::update_state(account_id, move |state| { + let mut updated = state.clone(); + if let Some(ref mut session) = updated.active_session { + for name in folders { + session.folder_details.insert( + name.clone(), + FolderProgress { + folder_name: name, + planned: 0, + current: 0, + status: FolderStatus::Pending, + message: None, + }, + ); + } + } + Ok(updated) + }) + .await + } + + pub async fn append_session_error(account_id: u64, error: String) -> BichonResult<()> { + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + if let Some(ref mut session) = updated.active_session { + let new_error = AccountError { + error, + at: utc_now!(), + }; + session.errors.push(new_error); + let to_remove = session.errors.len().saturating_sub(30); + if to_remove > 0 { + session.errors.drain(0..to_remove); + } + } + Ok(updated) + }) + .await + } + + async fn update_state( + account_id: u64, + updater: impl FnOnce(&DownloadState) -> BichonResult + Send + 'static, + ) -> BichonResult<()> { + if Self::get(account_id).await?.is_some() { + update_impl( + DB_MANAGER.envelope_db(), + move |rw| { + rw.get() + .primary::(account_id) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + .ok_or_else(|| { + raise_error!( + format!("Cannot find download info of account={}", account_id), + ErrorCode::ResourceNotFound + ) + }) + }, + updater, + ) + .await?; + } Ok(()) } @@ -110,12 +268,12 @@ impl AccountRunningState { delete_impl(DB_MANAGER.envelope_db(), move |rw| { rw.get() - .primary::(account_id) + .primary::(account_id) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| { raise_error!( format!( - "AccountRunningState '{}' not found during deletion process.", + "DownloadState '{}' not found during deletion process.", account_id ), ErrorCode::ResourceNotFound @@ -125,216 +283,38 @@ impl AccountRunningState { .await } - // pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> { - // Self::update_account_running_state(account_id, move |current| { - // let mut updated = current.clone(); - // updated.initial_sync_start_time = Some(utc_now!()); - // Ok(updated) - // }) - // .await - // } - - pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> { - Self::update_account_running_state(account_id, move |current| { + pub async fn append_global_error_message(account_id: u64, error: String) -> BichonResult<()> { + Self::update_state(account_id, move |current| { let mut updated = current.clone(); - updated.is_initial_sync_completed = true; - updated.initial_sync_end_time = Some(utc_now!()); + updated.append_global_error_log(error); Ok(updated) }) .await } - pub async fn set_initial_sync_failed(account_id: u64) -> BichonResult<()> { - Self::update_account_running_state(account_id, move |current| { - let mut updated = current.clone(); - updated.initial_sync_failed_time = Some(utc_now!()); - Ok(updated) - }) - .await - } - - pub async fn set_current_sync_batch_number( - account_id: u64, - syncing_folder: String, - batch_number: u32, - ) -> BichonResult<()> { - Self::update_account_running_state(account_id, move |current| { - let mut updated = current.clone(); - let mut progress_map = updated.progress.clone().unwrap_or_default(); - let entry = - progress_map - .entry(syncing_folder.to_string()) - .or_insert(MailboxBatchProgress { - total_batches: 0, - current_batch: 0, - }); - entry.current_batch = batch_number; - updated.progress = Some(progress_map); - Ok(updated) - }) - .await - } - - pub async fn set_folder_initial_sync_completed( - account_id: u64, - syncing_folder: String, - ) -> BichonResult<()> { - Self::update_account_running_state(account_id, move |current| { - let mut updated = current.clone(); - let mut progress_map = updated.progress.clone().unwrap_or_default(); - let entry = - progress_map - .entry(syncing_folder.to_string()) - .or_insert(MailboxBatchProgress { - total_batches: 0, - current_batch: 0, - }); - entry.current_batch = entry.total_batches; - updated.progress = Some(progress_map); - Ok(updated) - }) - .await - } - - pub async fn set_initial_current_syncing_folder( - account_id: u64, - current_syncing_folder: String, - total_sync_batches: u32, - ) -> BichonResult<()> { - Self::update_account_running_state(account_id, move |current| { - let mut updated = current.clone(); - let mut progress_map = updated.progress.clone().unwrap_or_default(); - progress_map.insert( - current_syncing_folder.clone(), - MailboxBatchProgress { - total_batches: total_sync_batches, - current_batch: 0, - }, - ); - updated.progress = Some(progress_map); - Ok(updated) - }) - .await - } - - pub async fn set_incremental_sync_start(account_id: u64) -> BichonResult<()> { - Self::update_account_running_state(account_id, move |current| { - let mut updated = current.clone(); - updated.last_incremental_sync_start = utc_now!(); - updated.last_incremental_sync_end = None; - Ok(updated) - }) - .await - } - - pub async fn set_incremental_sync_end(account_id: u64) -> BichonResult<()> { - Self::update_account_running_state(account_id, move |current| { - let mut updated = current.clone(); - updated.last_incremental_sync_end = Some(utc_now!()); - Ok(updated) - }) - .await - } - - pub async fn append_error_message(account_id: u64, error: String) -> BichonResult<()> { - Self::update_account_running_state(account_id, move |current| { - let mut updated = current.clone(); - updated.append_error_log(error); - Ok(updated) - }) - .await - } - - pub fn append_error_log(&mut self, error: String) { + fn append_global_error_log(&mut self, error: String) { let new_error = AccountError { error, at: utc_now!(), }; - self.errors.push(new_error); - if self.errors.len() > ERROR_COUNT_PER_ACCOUNT { - self.errors.remove(0); + self.global_errors.push(new_error); + let to_remove = self.global_errors.len().saturating_sub(30); + if to_remove > 0 { + self.global_errors.drain(0..to_remove); } } -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_insert_single_error() { - let mut account_state = AccountRunningState { - account_id: 1000u64, - last_incremental_sync_start: 1000, - last_incremental_sync_end: Some(2000), - errors: Vec::new(), - ..Default::default() - }; - - account_state.append_error_log(String::from("Error 1")); - assert_eq!(account_state.errors.len(), 1); - assert_eq!(account_state.errors[0].error, "Error 1"); + pub async fn clear_global_errors(account_id: u64) -> BichonResult<()> { + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + updated.clear_global_error_log(); + Ok(updated) + }) + .await } - - #[test] - fn test_insert_multiple_errors() { - let mut account_state = AccountRunningState { - account_id: 1000u64, - last_incremental_sync_start: 1000, - last_incremental_sync_end: Some(2000), - errors: Vec::new(), - ..Default::default() - }; - - for i in 1..=5 { - account_state.append_error_log(format!("Error {}", i)); - } - - assert_eq!(account_state.errors.len(), 5); - assert_eq!(account_state.errors[4].error, "Error 5"); - } - - #[test] - fn test_error_limit_exceeded() { - let mut account_state = AccountRunningState { - account_id: 1000u64, - last_incremental_sync_start: 1000, - last_incremental_sync_end: Some(2000), - errors: Vec::new(), - ..Default::default() - }; - - for i in 1..=25 { - account_state.append_error_log(format!("Error {}", i)); - } - - // Should only keep the last 10 errors - assert_eq!(account_state.errors.len(), ERROR_COUNT_PER_ACCOUNT); - assert_eq!(account_state.errors[0].error, "Error 6"); - assert_eq!(account_state.errors[19].error, "Error 25"); - } - - #[test] - fn test_insert_error_after_limit() { - let mut account_state = AccountRunningState { - account_id: 1000u64, - last_incremental_sync_start: 1000, - last_incremental_sync_end: Some(2000), - errors: Vec::new(), - ..Default::default() - }; - - // Insert exactly 10 errors - for i in 1..=20 { - account_state.append_error_log(format!("Error {}", i)); - } - - // Insert one more error to exceed the limit - account_state.append_error_log(String::from("Error 21")); - assert_eq!(account_state.errors.len(), ERROR_COUNT_PER_ACCOUNT); - assert_eq!(account_state.errors[0].error, "Error 2"); // The first error is removed - assert_eq!(account_state.errors[19].error, "Error 21"); // The last inserted error + fn clear_global_error_log(&mut self) { + self.global_errors.clear(); } } diff --git a/src/modules/cache/imap/mod.rs b/src/modules/cache/imap/mod.rs index 6be5731..310431a 100644 --- a/src/modules/cache/imap/mod.rs +++ b/src/modules/cache/imap/mod.rs @@ -16,10 +16,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use std::sync::LazyLock; -use crate::modules::{account::state::AccountRunningState, database::ModelsAdapter}; +use crate::modules::{ + account::{state::DownloadState, old_state::AccountRunningState}, + database::ModelsAdapter, +}; use ahash::{AHashMap, AHashSet}; use mailbox::MailBox; use native_db::Models; @@ -32,6 +34,7 @@ pub static MAILBOX_MODELS: LazyLock = LazyLock::new(|| { let mut adapter = ModelsAdapter::new(); adapter.register_model::(); adapter.register_model::(); + adapter.register_model::(); adapter.models }); diff --git a/src/modules/cache/imap/sync/flow.rs b/src/modules/cache/imap/sync/flow.rs index eb02d0e..f7f45f5 100644 --- a/src/modules/cache/imap/sync/flow.rs +++ b/src/modules/cache/imap/sync/flow.rs @@ -18,7 +18,10 @@ use crate::{ modules::{ - account::{migration::AccountModel, state::AccountRunningState}, + account::{ + migration::AccountModel, + state::{DownloadState, DownloadStatus, FolderStatus}, + }, cache::{ imap::{ find_intersecting_mailboxes, find_missing_mailboxes, @@ -38,9 +41,10 @@ use crate::{ }; use std::{sync::Arc, time::Instant}; use tokio::sync::Semaphore; +use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; -pub const DEFAULT_BATCH_SIZE: u32 = 50; +pub const DEFAULT_BATCH_SIZE: u32 = 30; #[derive(Clone, Debug, Eq, PartialEq)] pub enum FetchDirection { @@ -53,9 +57,26 @@ pub async fn fetch_and_save_by_date( date: &str, mailbox: &MailBox, direction: FetchDirection, -) -> BichonResult { + token: CancellationToken, +) -> BichonResult<()> { let account_id = account.id; - let mut session = ImapExecutor::create_connection(account_id).await?; + let mut session = match ImapExecutor::create_connection(account_id).await { + Ok(session) => session, + Err(e) => { + let err_msg = format!("Connection failed for this folder: {:#?}", e); + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + 0, + 0, + FolderStatus::Failed, + Some(err_msg.clone()), + ) + .await?; + DownloadState::append_session_error(account_id, err_msg).await?; + return Err(e); + } + }; let search_criteria = match direction { FetchDirection::Since => format!("SINCE {date}"), @@ -63,11 +84,38 @@ pub async fn fetch_and_save_by_date( }; let uid_list = - ImapExecutor::uid_search(&mut session, &mailbox.encoded_name(), &search_criteria).await?; + match ImapExecutor::uid_search(&mut session, &mailbox.encoded_name(), &search_criteria) + .await + { + Ok(uid_list) => uid_list, + Err(e) => { + let err_msg = format!("UID search failed in [{}]: {:#?}", mailbox.name, e); + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + 0, + 0, + FolderStatus::Failed, + Some(err_msg.clone()), + ) + .await?; + DownloadState::append_session_error(account_id, err_msg).await?; + return Err(e); + } + }; let len = uid_list.len(); if len == 0 { - return Ok(0); + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + 0, + 0, + FolderStatus::Success, + None, + ) + .await?; + return Ok(()); } let folder_limit = account.folder_limit; @@ -88,53 +136,156 @@ pub async fn fetch_and_save_by_date( } } - // let semaphore = Arc::new(Semaphore::new(5)); - + let planned = uid_vec.len() as u64; let uid_batches = generate_uid_sequence_hashset( uid_vec, account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize, false, ); - AccountRunningState::set_initial_current_syncing_folder( + DownloadState::update_folder_progress( account_id, mailbox.name.clone(), - uid_batches.len() as u32, + planned, + 0, + FolderStatus::Pending, + None, ) .await?; - for (index, batch) in uid_batches.into_iter().enumerate() { - AccountRunningState::set_current_sync_batch_number( - account_id, - mailbox.name.clone(), - (index + 1) as u32, - ) - .await?; + let mut current_processed = 0u64; + let mut has_error_or_cancel = false; + for (index, batch) in uid_batches.into_iter().enumerate() { + if token.is_cancelled() { + DownloadState::update_session_status( + account_id, + DownloadStatus::Cancelled, + Some("User stopped or system shutdown".to_string()), + ) + .await?; + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + planned, + current_processed, + FolderStatus::Cancelled, + None, + ) + .await?; + has_error_or_cancel = true; + break; + } // Fetch metadata for the current batch of UIDs - ImapExecutor::uid_batch_retrieve_emails( + match ImapExecutor::uid_batch_retrieve_emails( &mut session, account_id, mailbox.id, - &batch, - &mailbox.encoded_name(), + &batch.0, + token.clone(), + ) + .await + { + Ok(_) => { + current_processed += batch.1; + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + planned, + current_processed, + FolderStatus::Downloading, + None, + ) + .await?; + } + Err(e) => { + let err_msg = format!("Batch {} failed: {:#?}", index, e); + DownloadState::append_session_error(account_id, err_msg.clone()).await?; + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + planned, + current_processed, + FolderStatus::Failed, + Some(err_msg), + ) + .await?; + has_error_or_cancel = true; + break; + } + } + } + if !has_error_or_cancel { + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + planned, + current_processed, + FolderStatus::Success, + None, ) .await?; } session.logout().await.ok(); - Ok(len) + Ok(()) } pub async fn fetch_and_save_full_mailbox( account: &AccountModel, mailbox: &MailBox, - total: u32, -) -> BichonResult { + token: CancellationToken, +) -> BichonResult<()> { let mailbox_id = mailbox.id; let account_id = account.id; + + let mut session = match ImapExecutor::create_connection(account_id).await { + Ok(session) => session, + Err(e) => { + let err_msg = format!("Connection failed for this folder: {:#?}", e); + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + 0, + 0, + FolderStatus::Failed, + Some(err_msg.clone()), + ) + .await?; + DownloadState::append_session_error(account_id, err_msg).await?; + return Err(e); + } + }; + + let total = match session.examine(&mailbox.encoded_name()).await { + Ok(mailbox) => mailbox.exists as u64, + Err(e) => { + let err_msg = format!("Failed to examine folder [{}]: {:#?}", mailbox.name, e); + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + mailbox.exists as u64, + 0, + FolderStatus::Failed, + Some(err_msg.clone()), + ) + .await?; + + DownloadState::append_session_error(account_id, err_msg).await?; + session.logout().await.ok(); + return Err(raise_error!( + format!("{:#?}", e), + ErrorCode::ImapCommandFailed + )); + } + }; + let folder_limit = account.folder_limit; let total_to_fetch = match folder_limit { - Some(limit) if limit < total => total.min(limit.max(100)), + Some(limit) if (limit as u64) < total => { + let limit64 = limit as u64; + total.min(limit64.max(100)) + } _ => total, }; + let page_size = if let Some(limit) = folder_limit { limit .max(100) @@ -143,73 +294,102 @@ pub async fn fetch_and_save_full_mailbox( account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) }; - let total_batches = total_to_fetch.div_ceil(page_size); + let total_batches = total_to_fetch.div_ceil(page_size as u64); let desc = folder_limit.is_some(); - let mut inserted_count = 0; - - AccountRunningState::set_initial_current_syncing_folder( - account_id, - mailbox.name.clone(), - total_batches, - ) - .await?; info!( - "Starting full mailbox sync for '{}', total={}, limit={:?}, batches={}, desc={}", + "Starting full mailbox download for '{}', total={}, limit={:?}, batches={}, desc={}", mailbox.name, total, folder_limit, total_batches, desc ); - let mut session = ImapExecutor::create_connection(account_id).await?; + let mut current_processed = 0u64; + let mut has_error_or_cancel = false; for page in 1..=total_batches { - AccountRunningState::set_current_sync_batch_number(account_id, mailbox.name.clone(), page) + if token.is_cancelled() { + DownloadState::update_session_status( + account_id, + DownloadStatus::Cancelled, + Some("User stopped or system shutdown".to_string()), + ) .await?; - let count = ImapExecutor::batch_retrieve_emails( + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + total_to_fetch, + current_processed, + FolderStatus::Cancelled, + None, + ) + .await?; + has_error_or_cancel = true; + break; + } + + match ImapExecutor::batch_retrieve_emails( &mut session, account_id, mailbox_id, + total_to_fetch, page as u64, page_size as u64, &mailbox.encoded_name(), desc, + token.clone(), + ) + .await + { + Ok(count) => { + current_processed += count as u64; + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + total_to_fetch, + current_processed, + FolderStatus::Downloading, + None, + ) + .await?; + } + Err(e) => { + let err_msg = format!("Batch {} failed: {:#?}", page, e); + DownloadState::append_session_error(account_id, err_msg.clone()).await?; + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + total_to_fetch, + current_processed, + FolderStatus::Failed, + Some(err_msg), + ) + .await?; + has_error_or_cancel = true; + break; + } + }; + } + + if !has_error_or_cancel { + DownloadState::update_folder_progress( + account_id, + mailbox.name.clone(), + total_to_fetch, + current_processed, + FolderStatus::Success, + None, ) .await?; - inserted_count += count; - info!( - "Batch insertion completed for mailbox: {}, current page: {}, inserted count: {}", - &mailbox.name, page, count - ); } session.logout().await.ok(); - Ok(inserted_count) + Ok(()) } -/// # Example -/// -/// ```rust -/// use std::collections::HashSet; -/// -/// let mut uids = HashSet::new(); -/// uids.extend([1, 2, 3, 5, 6, 7, 9, 10, 11, 15]); -/// -/// let chunks = generate_uid_sequence_hashset(uids, 6, false); -/// assert_eq!(chunks, vec![ -/// "1:3,5:7".to_string(), -/// "9:11,15".to_string() -/// ]); -/// ``` -/// -/// This splits the UIDs into chunks of 6, compresses each chunk into ranges, -/// and returns a vector like: `["1:3,5:7", "9:11,15"]`. -/// pub fn generate_uid_sequence_hashset( unique_nums: Vec, chunk_size: usize, desc: bool, -) -> Vec { +) -> Vec<(String, u64)> { assert!(!unique_nums.is_empty()); - // let mut nums: Vec = unique_nums.into_iter().collect(); - // nums.sort(); let mut nums = unique_nums; if desc { nums.reverse(); @@ -218,8 +398,9 @@ pub fn generate_uid_sequence_hashset( let mut result = Vec::new(); for chunk in nums.chunks(chunk_size) { + let size = chunk.len() as u64; let compressed = compress_uid_list(chunk.to_vec()); - result.push(compressed); + result.push((compressed, size)); } result @@ -264,19 +445,50 @@ pub async fn reconcile_mailboxes( account: &AccountModel, remote_mailboxes: &[MailBox], local_mailboxes: &[MailBox], + token: CancellationToken, ) -> BichonResult<()> { let start_time = Instant::now(); let existing_mailboxes = find_intersecting_mailboxes(local_mailboxes, remote_mailboxes); let account_id = account.id; if !existing_mailboxes.is_empty() { let mut mailboxes_to_update = Vec::with_capacity(existing_mailboxes.len()); + + DownloadState::init_folder_details( + account.id, + remote_mailboxes.iter().map(|m| m.name.clone()).collect(), + ) + .await?; + for (local_mailbox, remote_mailbox) in &existing_mailboxes { + if token.is_cancelled() { + DownloadState::update_session_status( + account.id, + DownloadStatus::Cancelled, + Some("Received termination signal (User stop or System shutdown)".to_string()), + ) + .await?; + break; + } + if local_mailbox.uid_validity != remote_mailbox.uid_validity { if remote_mailbox.uid_validity.is_none() { - warn!( - "Account {}: Mailbox '{}' has invalid uid_validity (None). Skipping sync for this mailbox.", - account_id, local_mailbox.name + let err_msg = format!( + "Mailbox '{}' logic error: Server did not provide UIDVALIDITY.", + local_mailbox.name ); + + warn!("Account {}: {}", account_id, err_msg); + + DownloadState::update_folder_progress( + account_id, + remote_mailbox.name.clone(), + 0, + 0, + FolderStatus::Failed, + Some(err_msg.clone()), + ) + .await?; + DownloadState::append_session_error(account_id, err_msg).await?; continue; } info!( @@ -285,6 +497,16 @@ pub async fn reconcile_mailboxes( account_id, local_mailbox.name, &local_mailbox.uid_validity, &remote_mailbox.uid_validity ); + DownloadState::update_folder_progress( + account_id, + local_mailbox.name.clone(), + remote_mailbox.exists as u64, + 0, + FolderStatus::Downloading, + Some("UID validity changed, rebuilding...".into()), + ) + .await?; + match &account.date_since { Some(date_since) => { rebuild_mailbox_cache_by_date( @@ -293,6 +515,7 @@ pub async fn reconcile_mailboxes( &date_since.since_date()?, remote_mailbox, FetchDirection::Since, + token.clone(), ) .await?; } @@ -304,25 +527,24 @@ pub async fn reconcile_mailboxes( &r.calculate_date()?, remote_mailbox, FetchDirection::Before, + token.clone(), ) .await?; } None => { - rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await? + rebuild_mailbox_cache( + account, + local_mailbox, + remote_mailbox, + token.clone(), + ) + .await? } }, } } else { - perform_incremental_sync(account, local_mailbox, remote_mailbox).await?; - } - if let Some(state) = AccountRunningState::get(account.id).await? { - if !state.is_initial_sync_completed { - AccountRunningState::set_folder_initial_sync_completed( - account_id, - local_mailbox.name.clone(), - ) + perform_incremental_sync(account, local_mailbox, remote_mailbox, token.clone()) .await?; - } } mailboxes_to_update.push(remote_mailbox.clone()); @@ -339,17 +561,25 @@ pub async fn reconcile_mailboxes( ); let missing_mailboxes = find_missing_mailboxes(local_mailboxes, remote_mailboxes); + //Mail folders that are not locally need to be downloaded. if !missing_mailboxes.is_empty() { MailBox::batch_insert(&missing_mailboxes).await?; let mut handles = Vec::new(); - for mailbox in &missing_mailboxes { + if token.is_cancelled() { + DownloadState::update_session_status( + account.id, + DownloadStatus::Cancelled, + Some("Received termination signal (User stop or System shutdown)".to_string()), + ) + .await?; + break; + } if mailbox.exists > 0 { let account = account.clone(); let mailbox = mailbox.clone(); let local_semaphore = Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_PER_ACCOUNT)); - let global_permit = match SEMAPHORE.clone().acquire_owned().await { Ok(permit) => permit, Err(err) => { @@ -372,12 +602,11 @@ pub async fn reconcile_mailboxes( continue; } }; - + let token_clone = token.clone(); let handle: tokio::task::JoinHandle> = tokio::spawn(async move { let _global_permit = global_permit; let _local_permit = local_permit; - match &account.date_since { Some(date_since) => { rebuild_mailbox_cache_by_date( @@ -386,6 +615,7 @@ pub async fn reconcile_mailboxes( &date_since.since_date()?, &mailbox, FetchDirection::Since, + token_clone, ) .await } @@ -397,10 +627,14 @@ pub async fn reconcile_mailboxes( &r.calculate_date()?, &mailbox, FetchDirection::Before, + token_clone, ) .await } - None => rebuild_mailbox_cache(&account, &mailbox, &mailbox).await, + None => { + rebuild_mailbox_cache(&account, &mailbox, &mailbox, token_clone) + .await + } }, } }); @@ -408,13 +642,32 @@ pub async fn reconcile_mailboxes( } } + let mut has_error = false; + let mut last_err = None; + for task in handles { match task.await { - Ok(Ok(())) => {} - Ok(Err(err)) => return Err(err), - Err(e) => return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError)), + Ok(Ok(_)) => {} + Ok(Err(err)) => { + has_error = true; + tracing::error!("Folder sync task failed: {:#?}", err); + last_err = Some(err); + } + Err(e) => { + has_error = true; + tracing::error!("Task panicked or runtime error: {:#?}", e); + } } } + if has_error { + if let Some(e) = last_err { + return Err(e); + } + return Err(raise_error!( + "Some tasks failed".into(), + ErrorCode::InternalError + )); + } } Ok(()) } @@ -424,6 +677,7 @@ async fn perform_incremental_sync( account: &AccountModel, local_mailbox: &MailBox, remote_mailbox: &MailBox, + token: CancellationToken, ) -> BichonResult<()> { if remote_mailbox.exists > 0 { let local_max_uid = INDEX_MANAGER @@ -444,6 +698,7 @@ async fn perform_incremental_sync( local_mailbox, max_uid + 1, before_date.as_deref(), + token, ) .await?; session.logout().await.ok(); @@ -460,12 +715,12 @@ async fn perform_incremental_sync( date_since.since_date()?.as_str(), remote_mailbox, FetchDirection::Since, + token, ) .await?; } None => { - fetch_and_save_full_mailbox(account, remote_mailbox, remote_mailbox.exists) - .await?; + fetch_and_save_full_mailbox(account, remote_mailbox, token).await?; } } } diff --git a/src/modules/cache/imap/sync/mod.rs b/src/modules/cache/imap/sync/mod.rs index b82d0b9..02c13dc 100644 --- a/src/modules/cache/imap/sync/mod.rs +++ b/src/modules/cache/imap/sync/mod.rs @@ -16,24 +16,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::{ - modules::{ - account::{ - dispatcher::STATUS_DISPATCHER, - migration::{AccountModel, AccountType}, - state::AccountRunningState, - }, - cache::imap::{mailbox::MailBox, sync::flow::FetchDirection}, - error::BichonResult, - imap::executor::ImapExecutor, +use crate::modules::{ + account::{ + migration::{AccountModel, AccountType}, + state::{DownloadState, DownloadStatus}, }, - utc_now, + cache::imap::{mailbox::MailBox, sync::flow::FetchDirection}, + error::BichonResult, + imap::executor::ImapExecutor, }; use flow::reconcile_mailboxes; use rebuild::{rebuild_cache, rebuild_cache_by_date}; use std::time::Instant; -use sync_folders::get_sync_folders; -use sync_type::{determine_sync_type, SyncType}; +use sync_folders::get_download_folders; +use sync_type::{decide_next_download_task, DownloadTask}; +use tokio_util::sync::CancellationToken; use tracing::{debug, warn}; pub mod flow; @@ -41,30 +38,41 @@ pub mod rebuild; pub mod sync_folders; pub mod sync_type; -pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> { +pub async fn process_imap_download( + account: &AccountModel, + token: CancellationToken, +) -> BichonResult<()> { assert_eq!(account.account_type, AccountType::IMAP); let start_time = Instant::now(); let account_id = account.id; - let sync_type = determine_sync_type(account).await?; - if matches!(sync_type, SyncType::SkipSync) { + let download_task = decide_next_download_task(account).await?; + if matches!(download_task, DownloadTask::Idle) { return Ok(()); } - let mut session = ImapExecutor::create_connection(account_id).await?; - let remote_mailboxes = match get_sync_folders(account, &mut session).await { + let mut session = match ImapExecutor::create_connection(account_id).await { + Ok(session) => session, + Err(e) => { + DownloadState::update_session_status( + account_id, + DownloadStatus::Failed, + Some(format!("Failed to connect to IMAP server: {}", e)), + ) + .await?; + return Err(e); + } + }; + let remote_mailboxes = match get_download_folders(account, &mut session).await { Ok(mailboxes) => mailboxes, Err(err) => { - warn!( - account_id = account.id, - error = %err, - "Failed to get sync folders, logging out and skipping this account" - ); + let err_msg = format!("Failed to fetch mailboxes: {}", err); + warn!(account_id = account.id, error = %err, "{}", err_msg); + DownloadState::update_session_status(account_id, DownloadStatus::Failed, Some(err_msg)) + .await?; return Ok(()); } }; session.logout().await.ok(); - if matches!(sync_type, SyncType::InitialSync) { - AccountRunningState::add(account.id).await?; - // AccountRunningState::set_initial_sync_start(account_id).await?; + if matches!(download_task, DownloadTask::FullFetch) { let result = match &account.date_since { Some(date_since) => { rebuild_cache_by_date( @@ -72,6 +80,7 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> { &remote_mailboxes, &date_since.since_date()?, FetchDirection::Since, + token, ) .await } @@ -82,61 +91,48 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> { &remote_mailboxes, &r.calculate_date()?, FetchDirection::Before, + token, ) .await } - None => rebuild_cache(account, &remote_mailboxes).await, + None => rebuild_cache(account, &remote_mailboxes, token).await, }, }; match result { Ok(_) => { - AccountRunningState::set_initial_sync_completed(account_id).await?; + DownloadState::update_session_status(account_id, DownloadStatus::Success, None) + .await?; } Err(e) => { - STATUS_DISPATCHER - .append_error( - account_id, - format!("Initial sync failed for the account, error: {:#?}", e), - ) - .await; - AccountRunningState::set_initial_sync_failed(account_id).await?; + DownloadState::update_session_status( + account_id, + DownloadStatus::Failed, + Some(format!("Email Download interrupted: {:#?}", e)), + ) + .await?; } } return Ok(()); } - if let Some(state) = AccountRunningState::get(account_id).await? { - let now = utc_now!(); - const COOLDOWN_MS: i64 = 60 * 1000; - let mut should_skip = false; - if let Some(time) = state.initial_sync_end_time { - if now - time < COOLDOWN_MS { - should_skip = true; - } + let local_mailboxes = MailBox::list_all(account_id).await?; + match reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes, token).await { + Ok(_) => { + DownloadState::update_session_status(account_id, DownloadStatus::Success, None).await? } - if let Some(time) = state.initial_sync_failed_time { - if now - time < COOLDOWN_MS { - should_skip = true; - } - } - if should_skip { - return Ok(()); + Err(e) => { + DownloadState::update_session_status( + account_id, + DownloadStatus::Failed, + Some(format!("Email Download interrupted: {:#?}", e)), + ) + .await? } } - AccountRunningState::set_incremental_sync_start(account.id).await?; - let local_mailboxes = MailBox::list_all(account_id).await?; - reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes).await?; let elapsed_time = start_time.elapsed().as_secs(); debug!( "Account{{{}}} Incremental sync completed: {} seconds elapsed.", account.email, elapsed_time ); - - if let Some(state) = AccountRunningState::get(account.id).await? { - if !state.is_initial_sync_completed { - AccountRunningState::set_initial_sync_completed(account_id).await?; - } - } - AccountRunningState::set_incremental_sync_end(account_id).await?; Ok(()) } diff --git a/src/modules/cache/imap/sync/rebuild.rs b/src/modules/cache/imap/sync/rebuild.rs index e3320fb..fdf63f1 100644 --- a/src/modules/cache/imap/sync/rebuild.rs +++ b/src/modules/cache/imap/sync/rebuild.rs @@ -18,7 +18,10 @@ use crate::{ modules::{ - account::migration::AccountModel, + account::{ + migration::AccountModel, + state::{DownloadState, DownloadStatus, FolderStatus}, + }, cache::{ imap::{ mailbox::MailBox, @@ -31,29 +34,52 @@ use crate::{ }, raise_error, }; -use std::{sync::Arc, time::Instant}; +use std::sync::Arc; use tokio::sync::Semaphore; +use tokio_util::sync::CancellationToken; use tracing::{error, info}; -pub const DEFAULT_MAX_CONCURRENT_PER_ACCOUNT: usize = 5; +pub const DEFAULT_MAX_CONCURRENT_PER_ACCOUNT: usize = 3; pub async fn rebuild_cache( account: &AccountModel, remote_mailboxes: &[MailBox], + token: CancellationToken, ) -> BichonResult<()> { - let start_time = Instant::now(); - let mut total_inserted = 0; MailBox::batch_insert(remote_mailboxes).await?; + DownloadState::init_folder_details( + account.id, + remote_mailboxes.iter().map(|m| m.name.clone()).collect(), + ) + .await?; let local_semaphore = Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_PER_ACCOUNT)); let mut handles = Vec::new(); for mailbox in remote_mailboxes { + if token.is_cancelled() { + DownloadState::update_session_status( + account.id, + DownloadStatus::Cancelled, + Some("Received termination signal (User stop or System shutdown)".to_string()), + ) + .await?; + break; + } if mailbox.exists == 0 { info!( "Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.", account.id, &mailbox.name ); + DownloadState::update_folder_progress( + account.id, + mailbox.name.clone(), + 0, + 0, + FolderStatus::Success, + None, + ) + .await?; continue; } let account = account.clone(); @@ -81,32 +107,41 @@ pub async fn rebuild_cache( continue; } }; - - let handle: tokio::task::JoinHandle> = - tokio::spawn(async move { - let _global_permit = global_permit; - let _local_permit = local_permit; - - fetch_and_save_full_mailbox(&account, &mailbox, mailbox.exists).await - }); + let token_clone = token.clone(); + let handle: tokio::task::JoinHandle> = tokio::spawn(async move { + let _global_permit = global_permit; + let _local_permit = local_permit; + fetch_and_save_full_mailbox(&account, &mailbox, token_clone).await + }); handles.push(handle); } + let mut has_error = false; + let mut last_err = None; + for task in handles { match task.await { - Ok(Ok(count)) => { - total_inserted += count; + Ok(Ok(_)) => {} + Ok(Err(err)) => { + has_error = true; + tracing::error!("Folder sync task failed: {:#?}", err); + last_err = Some(err); + } + Err(e) => { + has_error = true; + tracing::error!("Task panicked or runtime error: {:#?}", e); } - Ok(Err(err)) => return Err(err), - Err(e) => return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError)), } } - let elapsed_time = start_time.elapsed().as_secs(); - info!( - "Rebuild account cache completed: {} envelopes inserted. {} secs elapsed. \ - This is a full data fetch as there was no local cache data available.", - total_inserted, elapsed_time - ); + if has_error { + if let Some(e) = last_err { + return Err(e); + } + return Err(raise_error!( + "Some tasks failed".into(), + ErrorCode::InternalError + )); + } Ok(()) } @@ -115,20 +150,43 @@ pub async fn rebuild_cache_by_date( remote_mailboxes: &[MailBox], date: &str, direction: FetchDirection, + token: CancellationToken, ) -> BichonResult<()> { - let start_time = Instant::now(); - let mut total_inserted = 0; MailBox::batch_insert(remote_mailboxes).await?; + DownloadState::init_folder_details( + account.id, + remote_mailboxes.iter().map(|m| m.name.clone()).collect(), + ) + .await?; let mut handles = Vec::new(); let local_semaphore = Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_PER_ACCOUNT)); for mailbox in remote_mailboxes { + if token.is_cancelled() { + DownloadState::update_session_status( + account.id, + DownloadStatus::Cancelled, + Some("Received termination signal (User stop or System shutdown)".to_string()), + ) + .await?; + break; + } if mailbox.exists == 0 { info!( "Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.", account.id, &mailbox.name ); + + DownloadState::update_folder_progress( + account.id, + mailbox.name.clone(), + 0, + 0, + FolderStatus::Success, + None, + ) + .await?; continue; } let account = account.clone(); @@ -158,34 +216,44 @@ pub async fn rebuild_cache_by_date( continue; } }; - - let handle: tokio::task::JoinHandle> = + let token_clone = token.clone(); + let handle: tokio::task::JoinHandle> = tokio::spawn(async move { let _global_permit = global_permit; let _local_permit = local_permit; - fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction).await + fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction, token_clone) + .await }); handles.push(handle); } + + let mut has_error = false; + let mut last_err = None; + for task in handles { match task.await { - Ok(Ok(count)) => { - total_inserted += count; + Ok(Ok(_)) => {} + Ok(Err(err)) => { + has_error = true; + tracing::error!("Folder sync task failed: {:#?}", err); + last_err = Some(err); + } + Err(e) => { + has_error = true; + tracing::error!("Task panicked or runtime error: {:#?}", e); } - Ok(Err(err)) => return Err(err), - Err(e) => return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError)), } } - let elapsed_time = start_time.elapsed().as_secs(); - let direction_desc = match direction { - FetchDirection::Since => "starting from the specified date", - FetchDirection::Before => "ending before the specified date", - }; - info!( - "Rebuild account cache completed: {} envelopes inserted. {} secs elapsed. \ - Data fetched from server {}: {}.", - total_inserted, elapsed_time, direction_desc, date - ); + if has_error { + if let Some(e) = last_err { + return Err(e); + } + return Err(raise_error!( + "Some tasks failed".into(), + ErrorCode::InternalError + )); + } + Ok(()) } @@ -193,6 +261,7 @@ pub async fn rebuild_mailbox_cache( account: &AccountModel, local_mailbox: &MailBox, remote_mailbox: &MailBox, + token: CancellationToken, ) -> BichonResult<()> { INDEX_MANAGER .delete_mailbox_envelopes(account.id, vec![local_mailbox.id]) @@ -204,15 +273,19 @@ pub async fn rebuild_mailbox_cache( account.id, &local_mailbox.name ); - return Ok(()); // Skip if the mailbox has no emails + DownloadState::update_folder_progress( + account.id, + remote_mailbox.name.clone(), + 0, + 0, + FolderStatus::Success, + None, + ) + .await?; + return Ok(()); } - let inserted_count = - fetch_and_save_full_mailbox(account, remote_mailbox, remote_mailbox.exists).await?; - info!( - "Account {}: Successfully rebuild mailbox cache, inserted {} envelopes for mailbox '{}'.", - account.id, inserted_count, &local_mailbox.name - ); + fetch_and_save_full_mailbox(account, remote_mailbox, token).await?; Ok(()) } @@ -222,6 +295,7 @@ pub async fn rebuild_mailbox_cache_by_date( date: &str, remote: &MailBox, direction: FetchDirection, + token: CancellationToken, ) -> BichonResult<()> { INDEX_MANAGER .delete_mailbox_envelopes(account.id, vec![local_mailbox_id]) @@ -232,13 +306,18 @@ pub async fn rebuild_mailbox_cache_by_date( account.id, &remote.name ); - return Ok(()); // Skip if the mailbox has no emails + DownloadState::update_folder_progress( + account.id, + remote.name.clone(), + 0, + 0, + FolderStatus::Success, + None, + ) + .await?; + return Ok(()); } - let count = fetch_and_save_by_date(account, date, remote, direction).await?; - info!( - "Account {}: Successfully rebuild mailbox cache, inserted {} envelopes for mailbox '{}'.", - account.id, count, &remote.name - ); + fetch_and_save_by_date(account, date, remote, direction, token).await?; Ok(()) } diff --git a/src/modules/cache/imap/sync/sync_folders.rs b/src/modules/cache/imap/sync/sync_folders.rs index c272b82..b8b34da 100644 --- a/src/modules/cache/imap/sync/sync_folders.rs +++ b/src/modules/cache/imap/sync/sync_folders.rs @@ -32,7 +32,7 @@ use crate::{ use async_imap::{types::Name, Session}; use tracing::{debug, info, warn}; -pub async fn get_sync_folders( +pub async fn get_download_folders( account: &AccountModel, session: &mut Session>, ) -> BichonResult> { @@ -110,7 +110,7 @@ pub async fn get_sync_folders( .iter() .map(|n| decode_mailbox_name!(n.name().to_string())) .collect(); - AccountModel::update_sync_folders(account.id, sync_folders).await?; + AccountModel::update_download_folders(account.id, sync_folders).await?; } else { warn!( "Account {}: No subscribed mailboxes found. This is unexpected — IMAP server should at least provide INBOX.", @@ -160,7 +160,7 @@ pub async fn detect_mailbox_changes( // Note: When all subscribed folders are deleted (remaining_sync_folders empty), // the system's default behavior is to automatically fall back to syncing // only the default folders (INBOX and Sent) in subsequent operations - AccountModel::update_sync_folders(account.id, remaining_sync_folders).await?; + AccountModel::update_download_folders(account.id, remaining_sync_folders).await?; } info!( diff --git a/src/modules/cache/imap/sync/sync_type.rs b/src/modules/cache/imap/sync/sync_type.rs index 6ff128c..de60438 100644 --- a/src/modules/cache/imap/sync/sync_type.rs +++ b/src/modules/cache/imap/sync/sync_type.rs @@ -18,47 +18,50 @@ use crate::{ modules::{ - account::{migration::AccountModel, state::AccountRunningState}, + account::{ + migration::AccountModel, + state::{DownloadState, TriggerType}, + }, error::BichonResult, }, utc_now, }; #[derive(Clone, Debug, Eq, PartialEq)] -pub enum SyncType { - /// Initial sync, used when fetching all messages for the first time. - InitialSync, - /// Incremental synchronization, typically used for updates or fetching new data since the last sync. - IncrementalSync, - /// Skip synchronization, used when it's not yet time to perform the next sync. - SkipSync, +pub enum DownloadTask { + FullFetch, + TraceFetch, + Idle, } -pub async fn determine_sync_type(account: &AccountModel) -> BichonResult { - Ok(match AccountRunningState::get(account.id).await? { - Some(info) => { - let now = utc_now!(); - let incremental_sync = is_time_for_incremental_sync( - now, - info.last_incremental_sync_start, +pub async fn decide_next_download_task(account: &AccountModel) -> BichonResult { + Ok(match DownloadState::get(account.id).await? { + Some(state) => { + let should_trigger = should_trigger_next_download( + state.last_trigger_at, + state.last_finished_at.unwrap_or(0), account.sync_interval_min.unwrap(), ); - if incremental_sync { - SyncType::IncrementalSync + if should_trigger { + DownloadState::start_new_session(account.id, TriggerType::Scheduled).await?; + DownloadTask::TraceFetch } else { - SyncType::SkipSync + DownloadTask::Idle } } - None => SyncType::InitialSync, + None => { + DownloadState::init(account.id).await?; + DownloadTask::FullFetch + } }) } -/// Check if it's time for an incremental sync based on the provided interval. -fn is_time_for_incremental_sync( - now: i64, - last_incremental_sync_at: i64, +fn should_trigger_next_download( + last_trigger_at: i64, + last_finished_at: i64, sync_interval_min: i64, ) -> bool { - now - last_incremental_sync_at > (sync_interval_min * 60 * 1000) + let now = utc_now!(); + now - last_trigger_at > (sync_interval_min * 60 * 1000) && now - last_finished_at > 60 * 1000 } diff --git a/src/modules/cache/imap/task.rs b/src/modules/cache/imap/task.rs index cb8da2b..e483ac5 100644 --- a/src/modules/cache/imap/task.rs +++ b/src/modules/cache/imap/task.rs @@ -17,18 +17,17 @@ // along with this program. If not, see . use crate::modules::account::entity::AuthType; -use crate::modules::cache::imap::sync::execute_imap_sync; +use crate::modules::account::state::DownloadState; +use crate::modules::cache::imap::sync::process_imap_download; use crate::modules::common::periodic::{PeriodicTask, TaskHandle}; use crate::modules::oauth2::token::OAuth2AccessToken; -use crate::modules::{ - account::{dispatcher::STATUS_DISPATCHER, migration::AccountModel}, - error::BichonResult, -}; +use crate::modules::{account::migration::AccountModel, error::BichonResult}; use crate::utc_now; use std::collections::HashMap; use std::sync::atomic::{AtomicI64, Ordering}; use std::{sync::LazyLock, time::Duration}; use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; static _DESCRIPTION: &str = "This task periodically synchronizes mailbox data for a specified account, ensuring that all local data is up-to-date."; @@ -38,7 +37,7 @@ static LAST_WARN_TIME: AtomicI64 = AtomicI64::new(0); const WARN_INTERVAL_MS: i64 = 600_000; pub struct AccountSyncTask { - tasks: Mutex>>, + tasks: Mutex>>, } impl AccountSyncTask { @@ -48,11 +47,16 @@ impl AccountSyncTask { } } - pub async fn start_account_sync_task(&self, account_id: u64, email: String) { - let task_name = format!("account-sync-task-{}-{}", account_id, &email); + pub async fn start_account_download_task(&self, account_id: u64, email: String) { + let task_name = format!("account-download-task-{}-{}", account_id, &email); let periodic_task = PeriodicTask::new(&task_name); + + let cancel_token = CancellationToken::new(); + let task_token = cancel_token.clone(); + let task = move |param: Option| { let account_id = param.unwrap(); + let internal_token = task_token.clone(); Box::pin(async move { let account = AccountModel::async_get(account_id).await.ok(); match account { @@ -63,7 +67,7 @@ impl AccountSyncTask { if now - last >= WARN_INTERVAL_MS { LAST_WARN_TIME.store(now, Ordering::Relaxed); warn!( - "Account {}: Sync aborted. Account is currently disabled.", + "Account {}: download aborted. Account is currently disabled.", account_id ); } @@ -72,21 +76,20 @@ impl AccountSyncTask { if let AuthType::OAuth2 = imap.auth.auth_type { if OAuth2AccessToken::get(account.id).await?.is_none() { if utc_now!() % 300_000 == 0 { - warn!("Account {}: Sync aborted. OAuth2 authorization not completed. Please visit the rustmailer admin page to authorize this account.", account_id); + warn!("Account {}: download aborted. OAuth2 authorization not completed. Please visit the rustmailer admin page to authorize this account.", account_id); } return Ok(()); } } } - if let Err(e) = execute_imap_sync(&account).await { - STATUS_DISPATCHER - .append_error( - account_id, - format!("error in account sync task: {:#?}", e), - ) - .await; + if let Err(e) = process_imap_download(&account, internal_token).await { + DownloadState::append_global_error_message( + account.id, + format!("error in account download task: {:#?}", e), + ) + .await?; error!( - "Failed to synchronize mailbox data for '{}': {:?}", + "Failed to download mailbox data for '{}': {:?}", account_id, e ) } @@ -94,7 +97,7 @@ impl AccountSyncTask { } None => { error!( - "Account {}: Sync aborted. Account entity not found.", + "Account {}: download aborted. Account entity not found.", account_id ); } @@ -103,10 +106,10 @@ impl AccountSyncTask { }) }; let handler = periodic_task.start(task, Some(account_id), TASK_INTERVAL, true, true); - self.add_task(account_id, handler).await; + self.add_task(account_id, (handler, cancel_token)).await; } - pub async fn add_task(&self, account_id: u64, handler: TaskHandle) { + pub async fn add_task(&self, account_id: u64, handler: (TaskHandle, CancellationToken)) { let mut guard = self.tasks.lock().await; if let Some(map) = guard.as_mut() { map.insert(account_id, handler); @@ -118,19 +121,12 @@ impl AccountSyncTask { pub async fn stop(&self, account_id: u64) -> BichonResult<()> { let mut guard = self.tasks.lock().await; if let Some(map) = guard.as_mut() { - if let Some(handler) = map.remove(&account_id) { + if let Some((handler, token)) = map.remove(&account_id) { drop(guard); + token.cancel(); handler.cancel().await; - } else { - warn!("No sync task found for account: {}", account_id); } - } else { - warn!( - "Stop called after global shutdown for account: {}", - account_id - ); } - Ok(()) } @@ -138,13 +134,21 @@ impl AccountSyncTask { let mut guard = self.tasks.lock().await; if let Some(map) = guard.take() { drop(guard); - for (account_id, handler) in map { - info!("Shutdown: Waiting for account {} to sync...", account_id); - handler.stop().await; + for (account_id, (handler, token)) in map { + info!( + "Shutdown: Sending cancel signal to account {}...", + account_id + ); + token.cancel(); + if let Err(_) = tokio::time::timeout(Duration::from_secs(10), handler.stop()).await + { + error!( + "Shutdown: Account {} download task forced timeout.", + account_id + ); + } } - info!("Shutdown: All sync tasks stopped successfully."); - } else { - warn!("Shutdown: Sync tasks were already shut down or never initialized."); + info!("Shutdown: All download tasks processed."); } } } diff --git a/src/modules/context/controller.rs b/src/modules/context/controller.rs index 1ee18ea..eacd501 100644 --- a/src/modules/context/controller.rs +++ b/src/modules/context/controller.rs @@ -16,30 +16,30 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::modules::{cache::imap::task::SYNC_TASKS, error::BichonResult}; use std::{sync::LazyLock, time::Duration}; use tokio::sync::mpsc; use tracing::{error, info}; -pub static SYNC_CONTROLLER: LazyLock = LazyLock::new(SyncController::new); +pub static DOWNLOAD_CONTROLLER: LazyLock = + LazyLock::new(DownloadController::new); -pub struct SyncController { - channel: mpsc::Sender<(u64, String)>, // Channel to trigger account sync by account ID +pub struct DownloadController { + channel: mpsc::Sender<(u64, String)>, // Channel to trigger account download by account ID } -impl SyncController { +impl DownloadController { pub fn new() -> Self { let (tx, mut rx) = mpsc::channel::<(u64, String)>(100); tokio::spawn(async move { while let Some((account_id, email)) = rx.recv().await { - match Self::start_syncer(account_id, email.clone()).await { + match Self::start_download(account_id, email.clone()).await { Ok(Some(_)) => {} Ok(None) => {} Err(err) => { error!( - "Failed to prepare and start syncer of account {{{}-{}}}, error: {:#?}", + "Failed to prepare and start download of account {{{}-{}}}, error: {:#?}", &account_id, &email, err ); } @@ -47,25 +47,25 @@ impl SyncController { } }); - SyncController { channel: tx } + DownloadController { channel: tx } } /// Trigger synchronization for a specific account pub async fn trigger_start(&self, account_id: u64, email: String) { if let Err(e) = self.channel.send((account_id, email)).await { error!( - "Failed to trigger synchronization for account={{{}}}, error: {:?}", + "Failed to trigger download for account={{{}}}, error: {:?}", account_id, e ); } } - async fn start_syncer(account_id: u64, email: String) -> BichonResult> { + async fn start_download(account_id: u64, email: String) -> BichonResult> { info!( - "Account syncer starting for account: {}-{}.", + "Account download starting for account: {}-{}.", account_id, email ); - SYNC_TASKS.start_account_sync_task(account_id, email).await; + SYNC_TASKS.start_account_download_task(account_id, email).await; tokio::time::sleep(Duration::from_millis(100)).await; Ok(Some(())) } diff --git a/src/modules/context/executors.rs b/src/modules/context/executors.rs index c54d2de..23624f0 100644 --- a/src/modules/context/executors.rs +++ b/src/modules/context/executors.rs @@ -20,7 +20,7 @@ use crate::modules::account::migration::AccountType; use crate::modules::context::Initialize; use crate::{ modules::{ - account::migration::AccountModel, context::controller::SYNC_CONTROLLER, error::BichonResult, + account::migration::AccountModel, context::controller::DOWNLOAD_CONTROLLER, error::BichonResult, }, utc_now, }; @@ -65,7 +65,7 @@ impl BichonContext { active_accounts.len() ); for account in active_accounts { - SYNC_CONTROLLER + DOWNLOAD_CONTROLLER .trigger_start(account.id, account.email) .await } diff --git a/src/modules/envelope/extractor.rs b/src/modules/envelope/extractor.rs index c452300..92710c7 100644 --- a/src/modules/envelope/extractor.rs +++ b/src/modules/envelope/extractor.rs @@ -182,6 +182,7 @@ async fn extract_envelope_core( mailbox_name: None, content_hash: email_content_hash, }; + // 'attachments' contains both regular and inline attachments let ea = EnvelopeWithAttachments { envelope, attachments: Some(attachments), diff --git a/src/modules/imap/executor.rs b/src/modules/imap/executor.rs index 9fd9334..aca6121 100644 --- a/src/modules/imap/executor.rs +++ b/src/modules/imap/executor.rs @@ -17,7 +17,7 @@ // along with this program. If not, see . use crate::modules::account::migration::AccountModel; -use crate::modules::account::state::AccountRunningState; +use crate::modules::account::state::{DownloadState, FolderStatus}; use crate::modules::cache::imap::mailbox::MailBox; use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE}; use crate::modules::envelope::extractor::extract_envelope_and_store_it; @@ -29,6 +29,7 @@ use async_imap::types::Name; use async_imap::Session; use futures::TryStreamExt; use std::collections::HashSet; +use tokio_util::sync::CancellationToken; use tracing::info; const BODY_FETCH_COMMAND: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[])"; @@ -85,6 +86,7 @@ impl ImapExecutor { mailbox: &MailBox, start_uid: u64, before: Option<&str>, + token: CancellationToken, ) -> BichonResult<()> { assert!(start_uid > 0, "start_uid must be greater than 0"); @@ -93,10 +95,39 @@ impl ImapExecutor { None => format!("UID {start_uid}:*"), }; - let uid_list = Self::uid_search(session, &mailbox.encoded_name(), &query).await?; + let uid_list = match Self::uid_search(session, &mailbox.encoded_name(), &query).await { + Ok(uid_list) => uid_list, + Err(e) => { + let err_msg = format!("UID search failed in [{}]: {:#?}", mailbox.name, e); + DownloadState::update_folder_progress( + account.id, + mailbox.name.clone(), + 0, + 0, + FolderStatus::Failed, + Some(err_msg.clone()), + ) + .await?; + DownloadState::append_session_error(account.id, err_msg).await?; + return Err(e); + } + }; let len = uid_list.len(); if len == 0 { + let msg = match before { + Some(date) => format!("No emails found before {}.", date), + None => "No new emails found.".into(), + }; + DownloadState::update_folder_progress( + account.id, + mailbox.name.clone(), + 0, + 0, + FolderStatus::Success, + Some(msg), + ) + .await?; return Ok(()); } info!( @@ -111,35 +142,63 @@ impl ImapExecutor { account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize, false, ); - - let too_many = len as u32 > 5 * account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE); - if too_many { - AccountRunningState::set_initial_current_syncing_folder( - account.id, - mailbox.name.clone(), - uid_batches.len() as u32, - ) - .await?; - } - + let mut current_processed = 0u64; + let mut has_error_or_cancel = false; for (index, batch) in uid_batches.into_iter().enumerate() { - if too_many { - AccountRunningState::set_current_sync_batch_number( - account.id, - mailbox.name.clone(), - (index + 1) as u32, - ) - .await?; + if token.is_cancelled() { + break; } - Self::uid_batch_retrieve_emails( + match Self::uid_batch_retrieve_emails( session, account.id, mailbox.id, - &batch, - &mailbox.encoded_name(), + &batch.0, + token.clone(), + ) + .await + { + Ok(_) => { + current_processed += batch.1; + DownloadState::update_folder_progress( + account.id, + mailbox.name.clone(), + len as u64, + current_processed, + FolderStatus::Downloading, + None, + ) + .await?; + } + Err(e) => { + let err_msg = format!("Batch {} failed: {:#?}", index, e); + DownloadState::append_session_error(account.id, err_msg.clone()).await?; + DownloadState::update_folder_progress( + account.id, + mailbox.name.clone(), + len as u64, + current_processed, + FolderStatus::Failed, + Some(err_msg), + ) + .await?; + has_error_or_cancel = true; + break; + } + } + } + + if !has_error_or_cancel { + DownloadState::update_folder_progress( + account.id, + mailbox.name.clone(), + len as u64, + current_processed, + FolderStatus::Success, + None, ) .await?; } + Ok(()) } @@ -147,24 +206,16 @@ impl ImapExecutor { session: &mut Session>, account_id: u64, mailbox_id: u64, + total: u64, page: u64, page_size: u64, encoded_mailbox_name: &str, desc: bool, + token: CancellationToken, ) -> BichonResult { assert!(page > 0, "Page number must be greater than 0"); assert!(page_size > 0, "Page size must be greater than 0"); - let total = session - .examine(encoded_mailbox_name) - .await - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))? - .exists as u64; - - if total == 0 { - return Ok(0); - } - let (start, end) = if desc { // Fetch messages starting from the newest (descending order) let end = total.saturating_sub((page - 1) * page_size); @@ -202,6 +253,13 @@ impl ImapExecutor { .await .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))? { + if token.is_cancelled() { + tracing::info!("Account {}: UID fetch stream interrupted.", account_id); + return Err(raise_error!( + "Stream cancelled".into(), + ErrorCode::InternalError + )); + } extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?; count += 1; } @@ -213,13 +271,8 @@ impl ImapExecutor { account_id: u64, mailbox_id: u64, uid_set: &str, - encoded_mailbox_name: &str, + token: CancellationToken, ) -> BichonResult<()> { - session - .examine(encoded_mailbox_name) - .await - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?; - let mut stream = session .uid_fetch(uid_set, BODY_FETCH_COMMAND) .await @@ -229,6 +282,13 @@ impl ImapExecutor { .await .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))? { + if token.is_cancelled() { + tracing::info!("Account {}: UID fetch stream interrupted.", account_id); + return Err(raise_error!( + "Stream cancelled".into(), + ErrorCode::InternalError + )); + } extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?; } Ok(()) diff --git a/src/modules/imap/manager.rs b/src/modules/imap/manager.rs index 0a75d1e..d87a01c 100644 --- a/src/modules/imap/manager.rs +++ b/src/modules/imap/manager.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::modules::account::dispatcher::STATUS_DISPATCHER; use crate::modules::account::entity::AuthType; use crate::modules::account::migration::{AccountModel, AccountType}; use crate::modules::error::code::ErrorCode; @@ -102,12 +101,6 @@ impl ImapConnectionManager { "Failed to create IMAP {}'s client: {:#?}", &account.email, error ); - STATUS_DISPATCHER - .append_error( - account_id, - format!("imap client connect error: {:#?}", error), - ) - .await; return Err(error); } }; @@ -116,12 +109,6 @@ impl ImapConnectionManager { Ok(session) => session, Err(error) => { error!("Failed to authenticate IMAP session: {:#?}", error); - STATUS_DISPATCHER - .append_error( - account_id, - format!("imap client authenticate error: {:#?}", error), - ) - .await; return Err(error); } }; @@ -132,12 +119,6 @@ impl ImapConnectionManager { AccountModel::update_capabilities(account_id, to_save).await?; if let Err(error) = check_capabilities(&capabilities) { error!("Failed to check IMAP capabilities: {:#?}", error); - STATUS_DISPATCHER - .append_error( - account_id, - format!("imap client check capabilities error: {:#?}", error), - ) - .await; return Err(error); } @@ -156,12 +137,6 @@ impl ImapConnectionManager { } Err(error) => { error!("Failed to fetch IMAP capabilities: {:#?}", error); - STATUS_DISPATCHER - .append_error( - account_id, - format!("imap client fetch capabilities error: {:#?}", error), - ) - .await; return Err(error); } } diff --git a/src/modules/message/content.rs b/src/modules/message/content.rs index 4769fe8..69a5b7f 100644 --- a/src/modules/message/content.rs +++ b/src/modules/message/content.rs @@ -50,6 +50,10 @@ pub struct AttachmentInfo { } impl AttachmentInfo { + pub fn is_inline(&self) -> bool { + self.inline && self.content_id.is_some() + } + pub fn get_extension(&self) -> Option { self.filename .as_deref() diff --git a/src/modules/rest/api/account.rs b/src/modules/rest/api/account.rs index cc21b94..725b05a 100644 --- a/src/modules/rest/api/account.rs +++ b/src/modules/rest/api/account.rs @@ -23,7 +23,7 @@ use crate::modules::account::migration::AccountModel; use crate::modules::account::payload::{ filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount, }; -use crate::modules::account::state::AccountRunningState; +use crate::modules::account::state::DownloadState; use crate::modules::account::view::AccountResp; use crate::modules::common::auth::ClientContext; use crate::modules::common::paginated::paginate_vec; @@ -195,15 +195,15 @@ impl AccountApi { /// The account ID to check state for account_id: Path, context: ClientContext, - ) -> ApiResult> { + ) -> ApiResult> { let account_id = account_id.0; AccountModel::check_account_exists(account_id).await?; context .require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS) .await?; - let state = AccountRunningState::get(account_id).await?.ok_or_else(|| { + let state = DownloadState::get(account_id).await?.ok_or_else(|| { raise_error!( - "account running state is not found".into(), + "account download state is not found".into(), ErrorCode::ResourceNotFound ) })?; diff --git a/src/modules/store/tantivy/fields.rs b/src/modules/store/tantivy/fields.rs index 267a0b7..3c656fc 100644 --- a/src/modules/store/tantivy/fields.rs +++ b/src/modules/store/tantivy/fields.rs @@ -39,7 +39,7 @@ pub const F_THREAD_ID: &str = "thread_id"; pub const F_ATTACHMENT_COUNT: &str = "attachment_count"; pub const F_REGULAR_ATTACHMENT_COUNT: &str = "regular_attachment_count"; pub const F_ATTACHMENTS: &str = "attachments"; -pub const F_ATTACHMENT_GLUE: &str = "attachment_glue"; +pub const F_ATTACHMENT_NAME: &str = "attachment_name"; pub const F_ATTACHMENT_CONTENT_HASH: &str = "attachment_content_hash"; pub const F_ATTACHMENT_EXT: &str = "attachment_ext"; pub const F_ATTACHMENT_CATEGORY: &str = "attachment_category"; @@ -70,7 +70,7 @@ pub struct EmailFields { pub f_attachment_count: Field, pub f_regular_attachment_count: Field, pub f_attachments: Field, - pub f_attachment_glue: Field, + pub f_attachment_name: Field, pub f_attachment_content_hash: Field, pub f_attachment_ext: Field, pub f_attachment_category: Field, diff --git a/src/modules/store/tantivy/manager.rs b/src/modules/store/tantivy/manager.rs index 99ebd11..b907589 100644 --- a/src/modules/store/tantivy/manager.rs +++ b/src/modules/store/tantivy/manager.rs @@ -359,7 +359,7 @@ impl IndexManager { } if let Some(ref name) = filter.attachment_name { - if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachment_glue) { + if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachment_name) { subqueries.push((Occur::Must, Box::new(query))); } } diff --git a/src/modules/store/tantivy/model.rs b/src/modules/store/tantivy/model.rs index 351460d..f31e7d6 100644 --- a/src/modules/store/tantivy/model.rs +++ b/src/modules/store/tantivy/model.rs @@ -53,28 +53,21 @@ impl EnvelopeWithAttachments { if let Some(ref atts) = self.attachments { let atts_json = serde_json::to_string(atts).unwrap_or_else(|_| "[]".to_string()); doc.add_text(fields.f_attachments, atts_json); - let mut search_terms = Vec::new(); - for att in atts { - if let Some(ref filename) = att.filename { - search_terms.push(filename.clone()); + if !att.is_inline() { + if let Some(ref filename) = att.filename { + doc.add_text(fields.f_attachment_name, filename); + } + if let Some(ext) = att.get_extension() { + doc.add_text(fields.f_attachment_ext, ext); + } + let category = att.get_category().to_string(); + doc.add_text(fields.f_attachment_category, category); + let file_type = att.file_type.to_lowercase(); + doc.add_text(fields.f_attachment_content_type, file_type); } - - if let Some(ext) = att.get_extension() { - search_terms.push(ext.clone()); - doc.add_text(fields.f_attachment_ext, ext); - } - let category = att.get_category().to_string(); - search_terms.push(category.clone()); - let file_type = att.file_type.to_lowercase(); - search_terms.push(file_type.clone()); - - doc.add_text(fields.f_attachment_category, category); - doc.add_text(fields.f_attachment_content_type, file_type); - doc.add_text(fields.f_attachment_content_hash, &att.content_hash); } - doc.add_text(fields.f_attachment_glue, search_terms.join(" ")); } doc.add_u64( diff --git a/src/modules/store/tantivy/schema.rs b/src/modules/store/tantivy/schema.rs index 48531ab..d4edb86 100644 --- a/src/modules/store/tantivy/schema.rs +++ b/src/modules/store/tantivy/schema.rs @@ -22,7 +22,7 @@ use tantivy::schema::{Schema, FAST, STORED, STRING, TEXT}; use crate::modules::store::tantivy::fields::{ EmailFields, F_ACCOUNT_ID, F_ATTACHMENTS, F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_HASH, - F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_COUNT, F_ATTACHMENT_EXT, F_ATTACHMENT_GLUE, F_BCC, + F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_COUNT, F_ATTACHMENT_EXT, F_ATTACHMENT_NAME, F_BCC, F_BODY, F_CC, F_CONTENT_HASH, F_DATE, F_FROM, F_ID, F_INGEST_AT, F_INTERNAL_DATE, F_MAILBOX_ID, F_MESSAGE_ID, F_PREVIEW, F_REGULAR_ATTACHMENT_COUNT, F_SHARD_ID, F_SIZE, F_SUBJECT, F_TAGS, F_THREAD_ID, F_TO, F_UID, @@ -50,7 +50,7 @@ impl SchemaTools { vec![ fields.f_subject, fields.f_body, - fields.f_attachment_glue, + fields.f_attachment_name, fields.f_from, fields.f_to, ] @@ -80,7 +80,7 @@ impl SchemaTools { let f_attachment_count = builder.add_u64_field(F_ATTACHMENT_COUNT, INDEXED | STORED | FAST); let f_regular_attachment_count = builder.add_u64_field(F_REGULAR_ATTACHMENT_COUNT, INDEXED | STORED | FAST); - let f_attachment_glue = builder.add_text_field(F_ATTACHMENT_GLUE, TEXT); + let f_attachment_name = builder.add_text_field(F_ATTACHMENT_NAME, TEXT); let f_attachments = builder.add_text_field(F_ATTACHMENTS, STORED); let f_attachment_content_hash = builder.add_text_field(F_ATTACHMENT_CONTENT_HASH, STRING | FAST | STORED); @@ -116,7 +116,7 @@ impl SchemaTools { f_attachment_count, f_regular_attachment_count, f_attachments, - f_attachment_glue, + f_attachment_name, f_attachment_content_hash, f_attachment_ext, f_attachment_category, diff --git a/src/modules/users/role.rs b/src/modules/users/role.rs index aeb8fe2..0124618 100644 --- a/src/modules/users/role.rs +++ b/src/modules/users/role.rs @@ -336,7 +336,7 @@ impl UserRole { .await?; Ok(()) } - + pub async fn delete(id: u64) -> BichonResult<()> { if is_builtin(id) { return Err(raise_error!( diff --git a/web/src/api/account/api.ts b/web/src/api/account/api.ts index 431bd77..be3d778 100644 --- a/web/src/api/account/api.ts +++ b/web/src/api/account/api.ts @@ -31,28 +31,58 @@ export const minimal_account_list = async () => { }; -export interface ErrorMessage { +export enum DownloadStatus { + Running = "Running", + Success = "Success", + Failed = "Failed", + Cancelled = "Cancelled", +} + +export enum TriggerType { + Manual = "Manual", + Scheduled = "Scheduled", +} + +export enum FolderStatus { + Pending = "Pending", + Downloading = "Downloading", + Success = "Success", + Failed = "Failed", + Cancelled = "Cancelled", +} + +export interface FolderProgress { + folder_name: string; + planned: number; + current: number; + status: FolderStatus; + message: string | null; +} + + +export interface AccountError { + at: number; error: string; - at: number; // milliseconds timestamp } -export type ProgressMap = Record; +export interface DownloadSession { + start_time: number; + end_time: number | null; + status: DownloadStatus; + message: string | null; + trigger: TriggerType; + folder_details: Record; + current_folder: string | null; + errors: AccountError[]; +} -export interface AccountRunningState { +export interface DownloadState { account_id: number; - last_incremental_sync_start: number; - last_incremental_sync_end?: number, - errors: ErrorMessage[]; - is_initial_sync_completed: boolean; - progress?: ProgressMap; - initial_sync_start_time?: number; - initial_sync_end_time?: number; -} - - -export interface MailboxBatchProgress { - total_batches: number; - current_batch: number; + active_session: DownloadSession | null; + history: DownloadSession[]; + last_trigger_at: number; + last_finished_at: number | null; + global_errors: AccountError[]; } @@ -61,6 +91,7 @@ type Encryption = 'Ssl' | 'StartTls' | 'None'; type AuthType = 'Password' | 'OAuth2'; type Unit = 'Days' | 'Months' | 'Years'; type AccountType = 'IMAP' | 'NoSync'; + // Interface definitions interface AuthConfig { auth_type: AuthType; @@ -109,7 +140,7 @@ export interface AccountModel { } export const account_state = async (account_id: number) => { - const response = await axiosInstance.get(`api/v1/account-state/${account_id}`); + const response = await axiosInstance.get(`api/v1/account-state/${account_id}`); return response.data; }; diff --git a/web/src/features/accounts/components/account-detail.tsx b/web/src/features/accounts/components/account-detail.tsx index 2309a7c..95ff984 100644 --- a/web/src/features/accounts/components/account-detail.tsx +++ b/web/src/features/accounts/components/account-detail.tsx @@ -190,7 +190,7 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) { - {t('accounts.syncFoldersTitle')} + {t('accounts.selectedMailboxes')} {currentRow.sync_folders?.length ? ( diff --git a/web/src/features/accounts/components/columns.tsx b/web/src/features/accounts/components/columns.tsx index 2a8f405..b960916 100644 --- a/web/src/features/accounts/components/columns.tsx +++ b/web/src/features/accounts/components/columns.tsx @@ -42,6 +42,7 @@ export function useColumns(): ColumnDef[] { }, enableSorting: false, enableHiding: false, + meta: { className: 'max-w-[120px]' }, }, { accessorKey: "email", @@ -52,6 +53,7 @@ export function useColumns(): ColumnDef[] { return {row.original.email} }, enableHiding: false, + meta: { className: 'max-w-[220px]' }, }, { accessorKey: "enabled", @@ -59,7 +61,7 @@ export function useColumns(): ColumnDef[] { ), cell: EnableAction, - meta: { className: 'w-18 text-center' }, + meta: { className: 'max-w-[86px] text-center' }, enableHiding: false, }, { @@ -68,7 +70,7 @@ export function useColumns(): ColumnDef[] { ), cell: OAuth2Action, - meta: { className: 'text-center' }, + meta: { className: 'text-center max-w-[86px]' }, enableHiding: false, enableSorting: false }, @@ -80,7 +82,7 @@ export function useColumns(): ColumnDef[] { cell: ({ row }) => { return {row.original.account_type} }, - meta: { className: 'w-18' }, + meta: { className: 'text-center max-w-[60px]' }, enableHiding: false, enableSorting: false }, @@ -96,7 +98,7 @@ export function useColumns(): ColumnDef[] { } return {row.original.sync_interval_min} min }, - //meta: { className: 'w-18 text-center' }, + meta: { className: 'text-center max-w-[120px]' }, enableHiding: false, }, { diff --git a/web/src/features/accounts/components/data-table-column-header.tsx b/web/src/features/accounts/components/data-table-column-header.tsx index 2986d7c..5f79a60 100644 --- a/web/src/features/accounts/components/data-table-column-header.tsx +++ b/web/src/features/accounts/components/data-table-column-header.tsx @@ -34,6 +34,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { useTranslation } from 'react-i18next' +import LongText from '@/components/long-text' interface DataTableColumnHeaderProps extends React.HTMLAttributes { @@ -59,7 +60,7 @@ export function DataTableColumnHeader({ size='sm' className=' h-8 data-[state=open]:bg-accent' > - {title} + {title} {column.getIsSorted() === 'desc' ? ( ) : column.getIsSorted() === 'asc' ? ( diff --git a/web/src/features/accounts/components/data-table-row-actions.tsx b/web/src/features/accounts/components/data-table-row-actions.tsx index a750487..9c55bfe 100644 --- a/web/src/features/accounts/components/data-table-row-actions.tsx +++ b/web/src/features/accounts/components/data-table-row-actions.tsx @@ -67,7 +67,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { {t('accounts.openMenu')} - + {hasPermission && { setCurrentRow(row.original) @@ -90,7 +90,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { setOpen('sync-folders') }} > - {t('accounts.syncFolders')} + {t('accounts.selectMailboxes')} diff --git a/web/src/features/accounts/components/sync-folders.tsx b/web/src/features/accounts/components/download-folders.tsx similarity index 98% rename from web/src/features/accounts/components/sync-folders.tsx rename to web/src/features/accounts/components/download-folders.tsx index 42b6db9..12c93f5 100644 --- a/web/src/features/accounts/components/sync-folders.tsx +++ b/web/src/features/accounts/components/download-folders.tsx @@ -145,7 +145,7 @@ interface Props { currentRow: AccountModel } -export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) { +export function DownloadFoldersDialog({ currentRow, open, onOpenChange }: Props) { const [selectedItems, setSelectedItems] = React.useState([]); const [isSubmitting, setIsSubmitting] = useState(false); @@ -382,9 +382,9 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) { - {t('accounts.selectSyncFolders')} + {t('accounts.selectMailboxes')} - {t('accounts.chooseFoldersToSync', { "email": currentRow.email })} + {t('accounts.chooseMailboxesToDownload', { "email": currentRow.email })} diff --git a/web/src/features/accounts/components/folder-sync-progress.tsx b/web/src/features/accounts/components/folder-sync-progress.tsx deleted file mode 100644 index 86f9b6c..0000000 --- a/web/src/features/accounts/components/folder-sync-progress.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// -// Copyright (c) 2025-2026 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 . - -import { ProgressMap } from "@/api/account/api"; -import { Progress } from "@/components/ui/progress"; -import { useTranslation } from "react-i18next"; - -interface Props { - progressMap: ProgressMap | undefined | null; -} - -export function FolderSyncProgress({ progressMap }: Props) { - const { t } = useTranslation(); - - if (!progressMap || Object.keys(progressMap).length === 0) { - return
{t('accounts.folderSync.noData')}
; - } - - const folderNames = Object.keys(progressMap); - - if (folderNames.length === 0) { - return
{t('accounts.folderSync.noFolders')}
; - } - - return ( -
- {folderNames.map((folder) => { - const progress = progressMap[folder]; - const percentage = - progress.total_batches > 0 - ? Math.min((progress.current_batch / progress.total_batches) * 100, 100) - : 0; - const isComplete = progress.current_batch >= progress.total_batches; - const textColor = isComplete ? "text-green-800" : "text-blue-800"; - const progressColor = isComplete - ? "bg-gray-200 [&>div]:bg-green-800 [&>div]:rounded-full h-1.5" - : "bg-gray-200 [&>div]:bg-blue-800 [&>div]:rounded-full h-1.5"; - - return ( -
-
- {folder} - - {progress.current_batch}/{progress.total_batches} {t('accounts.folderSync.batches')} - -
- -
- ); - })} -
- ); -} diff --git a/web/src/features/accounts/components/running-state-dialog.tsx b/web/src/features/accounts/components/running-state-dialog.tsx index b9eb4a0..683464d 100644 --- a/web/src/features/accounts/components/running-state-dialog.tsx +++ b/web/src/features/accounts/components/running-state-dialog.tsx @@ -26,15 +26,27 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { useQuery } from '@tanstack/react-query' -import { account_state, AccountModel } from '@/api/account/api' -import { formatDistanceToNow, formatDuration, intervalToDuration } from 'date-fns' +import { account_state, AccountModel, FolderProgress } from '@/api/account/api' +import { format } from 'date-fns' import { ScrollArea } from '@/components/ui/scroll-area' -import { Skeleton } from '@/components/ui/skeleton' -import { CheckCircle, Clock, Loader2, PlayCircle, FolderSync, FolderCheck } from 'lucide-react' -import { FolderSyncProgress } from './folder-sync-progress' +import { Badge } from '@/components/ui/badge' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { + CheckCircle, + Clock, + Loader2, + Activity, + AlertTriangle, + Info, +} from 'lucide-react' +import LongText from '@/components/long-text' import { useTranslation } from 'react-i18next' -import { dateFnsLocaleMap } from '@/lib/utils' -import { enUS } from 'date-fns/locale' interface Props { open: boolean @@ -42,241 +54,433 @@ interface Props { currentRow: AccountModel } +function StatusBadge({ status }: { status: string }) { + const map: Record = { + Running: 'bg-blue-100 text-blue-700', + Downloading: 'bg-blue-100 text-blue-700', + Success: 'bg-green-100 text-green-700', + Failed: 'bg-red-100 text-red-700', + Cancelled: 'bg-gray-100 text-gray-700', + Pending: 'bg-amber-100 text-amber-700', + } + return ( + + {status} + + ) +} + +function TriggerBadge({ trigger }: { trigger: string }) { + const isScheduled = trigger === 'Scheduled' + return ( + + {trigger} + + ) +} + + +function FolderDetailItem({ f, t }: { f: FolderProgress, t: (key: string) => string }) { + const percentage = Math.min(Math.round((f.current / f.planned) * 100), 100) || 0; + + return ( +
+
+
+ + {f.folder_name} + +
+
+
+
+
+
+ {percentage}% +
+
+
+ {f.current} + / + {f.planned} +
+
+ +
+
+
+
+ + {f.message && ( +
+
+ +
+

{t('accounts.runningState.message')}:

+

{f.message}

+
+
+
+ )} +
+ ) +} + export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) { - const { t, i18n } = useTranslation() - const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS; + const { t } = useTranslation(); + const { data: state, isLoading } = useQuery({ queryKey: ['running-state', currentRow.id], queryFn: () => account_state(currentRow.id), - retry: 0, - refetchOnWindowFocus: false, refetchInterval: 5000, - enabled: open && !!currentRow.id && currentRow.account_type != "NoSync", + enabled: open && !!currentRow.id, }) - const calculateDuration = (start?: number, end?: number) => { - if (!start) { - return ( - - {t('accounts.runningState.notStarted')} - - ) - } - if (!end) { - return ( - - {t('accounts.runningState.inProgress')} - - ) - } - const duration = intervalToDuration({ start: new Date(start), end: new Date(end) }) - return ( - - {formatDuration(duration, { format: ['hours', 'minutes', 'seconds'] })} - - ) - } + const session = state?.active_session + const history = state?.history || [] + const globalErrors = state?.global_errors || [] return ( - - - - {currentRow.email} - + + +
+ + + {currentRow.email} + +

+ {t('accounts.runningState.account.id')}: {currentRow.id} +

+
- - {isLoading && ( -
- - -
- -
-
- )} - {!isLoading && state && ( -
-
- {/* Initial Sync */} -
-
-

- {state.is_initial_sync_completed ? ( - + +
+ + + {t('accounts.runningState.tabs.active_session')} + + + {t('accounts.runningState.tabs.history')} + {history.length} + + + {t('accounts.runningState.tabs.global_errors')} + {globalErrors.length > 0 && {globalErrors.length}} + + +
+ +
+ {isLoading ? ( +
+ +

{t('accounts.runningState.loading.fetching_account_state')}

+
+ ) : ( + <> + + +
+ {!session ? ( +
+ +

{t('accounts.runningState.empty.no_active_download')}

+
) : ( - - )} - {t('accounts.runningState.initialSync')} -

- {state.is_initial_sync_completed ? ( - - {t('accounts.runningState.completed')} - - ) : ( - - {t('accounts.runningState.inProgress')} - - )} -
-
- {/* Start Time */} -
- {t('accounts.runningState.startTime')} - - {state.initial_sync_start_time ? ( - - {formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true, locale })} - - ) : ( - - - - - - {t('accounts.runningState.notStarted')} - - )} - -
- {/* End Time */} -
- {t('accounts.runningState.endTime')} - - {state.initial_sync_end_time ? ( - - {formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true, locale })} - - ) : state.initial_sync_start_time ? ( - - {t('accounts.runningState.inProgress')} - - ) : ( - {t('accounts.runningState.notStarted')} - )} - -
- {/* Duration */} -
- {t('accounts.runningState.duration')} - - {(() => { - if (!state.initial_sync_start_time) return {t('accounts.runningState.notStarted')} - if (!state.initial_sync_end_time) return {t('accounts.runningState.calculating')} - const diff = new Date(state.initial_sync_end_time).getTime() - new Date(state.initial_sync_start_time).getTime() - const h = Math.floor(diff / 3600000) - const m = Math.floor((diff % 3600000) / 60000) - const s = Math.floor((diff % 60000) / 1000) - return {h}h {m}m {s}s - })()} - -
-
-
- -
- {state && ( -
-

{t('accounts.runningState.syncProgress')}

- - - -
- )} -
- - {/* Incremental Sync */} -
-

{t('accounts.runningState.incrementalSync')}

-
-
-
- {t('accounts.runningState.startTime')} - - {state.last_incremental_sync_start ? ( - formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true, locale }) - ) : ( - {t('accounts.runningState.notStarted')} - )} - -
-
- {t('accounts.runningState.endTime')} - - {state.last_incremental_sync_end ? ( - formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true, locale }) - ) : state.last_incremental_sync_start ? ( - {t('accounts.runningState.inProgress')} - ) : ( - {t('accounts.runningState.notStarted')} - )} - -
-
-
-
- {t('accounts.runningState.duration')} - - {calculateDuration(state.last_incremental_sync_start, state.last_incremental_sync_end)} - -
-
- {t('accounts.runningState.syncInterval')} - - {t('accounts.runningState.everyMinutes', { minutes: currentRow.sync_interval_min })} - -
-
-
-
-
- -
-

{t('accounts.runningState.errorLogs')}

- -
- {state.errors.length ? ( - state.errors - .sort((a, b) => b.at - a.at) - .map((item, index) => ( -
-
-
- {formatDistanceToNow(new Date(item.at), { addSuffix: true, locale })} +
+
+
+

{t('accounts.runningState.session.status')}

+ +
+
+

{t('accounts.runningState.session.trigger')}

+ +
+
+

{t('accounts.runningState.session.started_at')}

+
+ {format(new Date(session.start_time), 'yyyy-MM-dd HH:mm:ss')}
-
{item.error}
- )) - ) : ( -
-

{t('accounts.runningState.noErrorLogs')}

-
- )} -
- -
-
- )} + + + + {t('accounts.runningState.tabs.folders')} + + + {t('accounts.runningState.tabs.errors')} ({session.errors?.length || 0}) + + + + {Object.values(session.folder_details).map((f: FolderProgress) => ( + + ))} + + + {(!session.errors || session.errors.length === 0) ? ( +
+ {t('accounts.runningState.empty.no_errors_current')} +
+ ) : ( +
+
+
+ {[...session.errors] + .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()) + .map((err, ei) => ( +
+
+ {ei === 0 ? ( + + + + + ) : ( +
+ )} +
+
+
+
+ {ei === 0 && ( + + {t('accounts.runningState.latest')} + + )} + + {format(new Date(err.at), 'yyyy-MM-dd HH:mm:ss')} + +
+ +
+

+ {err.error} +

+
+
+ ))} +
+
+ )} + + +
+ )} +
+ +
+ + +
+ {history.length === 0 ? ( +
{t('accounts.runningState.empty.no_history')}
+ ) : ( + + {[...history].reverse().map((h, i) => ( + + +
+
+
+ {format(new Date(h.start_time), 'yyyy-MM-dd HH:mm:ss')} +
+ +
+
+ + {Object.keys(h.folder_details).length} {t('accounts.runningState.folders')} + · + {Object.values(h.folder_details).reduce((sum, f) => sum + (f.current || 0), 0)} + / + {Object.values(h.folder_details).reduce((sum, f) => sum + (f.planned || 0), 0)} + +
+
+ + + + {t('accounts.runningState.tabs.folders')} + + {t('accounts.runningState.tabs.errors')} ({h.errors?.length || 0}) + + + + {Object.values(h.folder_details).map((f: any, idx) => ( + + ))} + + + {(!h.errors || h.errors.length === 0) ? ( +
+ {t('accounts.runningState.empty.no_errors_session')} +
+ ) : ( +
+
+
+ {[...h.errors] + .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()) + .map((err, ei) => ( +
+
+ {ei === 0 ? ( + + + + + ) : ( +
+ )} +
+
+
+
+ {ei === 0 && ( + + {t('accounts.runningState.latest')} + + )} + + {format(new Date(err.at), 'yyyy-MM-dd HH:mm:ss')} + +
+ +
+

+ {err.error} +

+
+
+ ))} +
+
+ )} + + + + + ))} + + )} +
+ + + + +
+ {globalErrors.length === 0 ? ( +
+ +

{t('accounts.runningState.empty.no_global_errors')}

+
+ ) : ( +
+
+
+ {[...globalErrors] + .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()) + .map((e, i) => ( +
+
+ {i === 0 ? ( + + + + + ) : ( +
+ )} +
+
+
+
+ {i === 0 && ( + + {t('accounts.runningState.latest')} + + )} - {!isLoading && !state && ( -
-

{t('accounts.runningState.noActiveState')}

-

{t('accounts.runningState.accountNotStarted')}

-
- )} - +
+ + {format(new Date(e.at), 'HH:mm')} + + + {format(new Date(e.at), 'yyyy-MM-dd HH:mm:ss')} + +
+
- + +
+
+

+ {e.error} +

+
+
+
+ ))} +
+
+ )} +
+ + + + )} +
+ + - +
) -} +} \ No newline at end of file diff --git a/web/src/features/accounts/index.tsx b/web/src/features/accounts/index.tsx index 829dfd0..f5d9040 100644 --- a/web/src/features/accounts/index.tsx +++ b/web/src/features/accounts/index.tsx @@ -37,7 +37,7 @@ import { useQuery } from '@tanstack/react-query' import { OAuth2TokensDialog } from './components/oauth2-tokens' import { RunningStateDialog } from './components/running-state-dialog' import { FixedHeader } from '@/components/layout/fixed-header' -import { SyncFoldersDialog } from './components/sync-folders' +import { DownloadFoldersDialog } from './components/download-folders' import { NoSyncAccountDialog } from './components/nosync-dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useTranslation } from 'react-i18next' @@ -192,7 +192,7 @@ export default function Accounts() { }} currentRow={currentRow} /> - { diff --git a/web/src/features/dashboard/index.tsx b/web/src/features/dashboard/index.tsx index 386585c..69b86ac 100644 --- a/web/src/features/dashboard/index.tsx +++ b/web/src/features/dashboard/index.tsx @@ -8,21 +8,13 @@ // 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 . - import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Skeleton } from '@/components/ui/skeleton'; import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, BarChart, Bar } from 'recharts'; -import { Mail, HardDrive, Database, Users, Inbox, Info } from 'lucide-react'; +import { Mail, Users, Inbox, Zap } from 'lucide-react'; import { formatBytes, formatNumber } from '@/lib/utils'; import { useQuery } from '@tanstack/react-query'; import { get_dashboard_stats, INITIAL_DASHBOARD_STATS, TimeBucket } from '@/api/system/api'; @@ -32,12 +24,32 @@ import { useTranslation } from 'react-i18next'; import { getToken } from '@/stores/authStore'; import { useNavigate } from '@tanstack/react-router'; import useMinimalAccountList from '@/hooks/use-minimal-account-list'; +import { Badge } from '@/components/ui/badge'; interface DailyActivity { date: string; count: number; + timestamp_ms: number; } +const GithubIcon = ({ className }: { className?: string }) => ( + + + + +); + function convertRecentActivity(timeBuckets: TimeBucket[], locale: string): DailyActivity[] { const dateFormatter = new Intl.DateTimeFormat(locale, { month: 'short', @@ -65,7 +77,6 @@ const formatTooltipDate = (timestamp_ms: number, locale: string): string => { const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']; -// Skeleton Components const MetricCardSkeleton = () => ( @@ -79,17 +90,6 @@ const MetricCardSkeleton = () => ( ); -const ChartSkeleton = () => ( -
-
- {[...Array(6)].map((_, i) => ( - - ))} -
-
-); - -// Empty State Components const EmptyChart = ({ title }: { title: string }) => (
@@ -113,12 +113,22 @@ export default function MailArchiveDashboard() { placeholderData: INITIAL_DASHBOARD_STATS, }); - const navigate = useNavigate(); const { t, i18n } = useTranslation(); - const currentLocale = i18n.resolvedLanguage || i18n.language || navigator.language; + const logicalSize = stats?.total_size_bytes ?? 0; + const blobSize = stats?.storage_usage_bytes ?? 0; + const indexSize = stats?.index_usage_bytes ?? 0; + const physicalTotal = blobSize + indexSize; + + const savingsPercent = logicalSize > 0 + ? Math.max(0, ((1 - physicalTotal / logicalSize) * 100)).toFixed(1) + : "0.0"; + + const blobWidth = logicalSize > 0 ? (blobSize / logicalSize) * 100 : 0; + const indexWidth = logicalSize > 0 ? (indexSize / logicalSize) * 100 : 0; + const totalAttachments = (stats?.with_attachment_count ?? 0) + (stats?.without_attachment_count ?? 0); const attachmentRatio = totalAttachments > 0 ? (stats?.with_attachment_count ?? 0) / totalAttachments : 0; @@ -134,8 +144,6 @@ export default function MailArchiveDashboard() { return account ? account.id : null; }; - - const handleQuickSearch = (filter: Record) => { navigate({ to: '/search', @@ -161,32 +169,10 @@ export default function MailArchiveDashboard() { if (isLoading) { return (
-
-
- - -
- -
- -
- {[...Array(6)].map((_, i) => ( - - ))} -
- -
- - - - - - - - - - +
+ {[...Array(4)].map((_, i) => )}
+
); } @@ -196,82 +182,92 @@ export default function MailArchiveDashboard() {
-
- +
+ {t('dashboard.mailAccounts')} -
{formatNumber(stats!.account_count)}
+
{formatNumber(stats!.account_count)}

{t('dashboard.connected')}

- + {t('dashboard.totalEmails')} -
{formatNumber(stats!.email_count)}
+
{formatNumber(stats!.email_count)}

{t('dashboard.syncedLocally')}

- + - {t('dashboard.totalEmailSize')} - + + + {t('dashboard.efficiency', 'Efficiency')} + +
+ {savingsPercent}% + {t('dashboard.saved', 'Saved')} +
- -
{formatBytes(stats!.total_size_bytes)}
-

{t('dashboard.logicalVolume')}

+ +
+
+
+
+ +
+
+
+ {t('dashboard.logicalVolume')}: + {formatBytes(logicalSize)} +
+
+ {t('dashboard.actualDiskUsage')}: + {formatBytes(physicalTotal)} +
+
+
+
+
+ + {t('dashboard.dataStorage')}: {formatBytes(blobSize)} + +
+
+
+ + {t('dashboard.indexSize')}: {formatBytes(indexSize)} + +
+
+
- - - {t('dashboard.localDataFiles')} - - - -
{formatBytes(stats!.storage_usage_bytes)}
-

{t('dashboard.actualDiskUsage')}

-
-
- - - - {t('dashboard.metadataSize')} - - - -
{formatBytes(stats!.index_usage_bytes)}
-

{t('dashboard.duckdbDatabase')}

-
-
- + {t('dashboard.systemVersion')} - + Community -
+
{stats!.system_version ? ( - + {stats!.system_version} ) : 'N/A'}
-

- {stats!.commit_hash ?? 'N/A'} -

+
+ +

{stats!.commit_hash?.substring(0, 7) ?? 'N/A'}

+
@@ -293,7 +289,7 @@ export default function MailArchiveDashboard() { {hasRecentActivity ? ( - + - + ) : ( @@ -348,6 +339,7 @@ export default function MailArchiveDashboard() { outerRadius={100} paddingAngle={5} dataKey="value" + stroke="none" > {attachmentData.map((entry, i) => ( @@ -356,67 +348,30 @@ export default function MailArchiveDashboard() { totalAttachments > 0 ? `${((v as number) * 100).toFixed(1)}%` : '0%'} /> -
- {totalAttachments > 0 ? ( - <> -
-
- - {t('dashboard.withAttachments')} ({(attachmentRatio * 100).toFixed(1)}%) - -
-
-
- {t('dashboard.noAttachments')} -
- - ) : ( -
-
- {t('common.noData')} -
- )} -
- + - {t('dashboard.top10Senders')} - {t('dashboard.byMessageCount')} + {t('dashboard.top10Senders')} - + {hasTopSenders ? ( - {t('dashboard.sender')} - {t('dashboard.count')} + {t('dashboard.sender')} + {t('dashboard.count')} {stats!.top_senders.map((s) => ( - handleQuickSearch({ from: s.key })} - > - -
-
- - {s.key} - - - - {formatNumber(s.count)} - + handleQuickSearch({ from: s.key })}> + {s.key} + {formatNumber(s.count)} ))} @@ -426,42 +381,25 @@ export default function MailArchiveDashboard() { )} - + + - {t('dashboard.top10LargestEmails')} - {t('dashboard.byMessageSize')} + {t('dashboard.top10LargestEmails')} - + {hasTopEmails ? (
- {t('dashboard.subject')} - {t('dashboard.size')} + {t('dashboard.subject')} + {t('dashboard.size')} - {stats!.top_largest_emails.map((m, index) => ( - handleQuickSearch({ subject: `"${m.subject}"` })} - > - -
-
- - - {m.subject || t('dashboard.noSubject')} - - - - - {formatBytes(m.size_bytes)} - + {stats!.top_largest_emails.map((m, i) => ( + handleQuickSearch({ subject: m.subject })}> + {m.subject || t('dashboard.noSubject')} + {formatBytes(m.size_bytes)} ))} @@ -471,51 +409,27 @@ export default function MailArchiveDashboard() { )} - + + - {t('dashboard.top10Accounts')} - {t('dashboard.byMessageCount')} + {t('dashboard.top10Accounts')} - + {hasTopAccounts ? (
- {t('dashboard.account')} - {t('dashboard.emails')} + {t('dashboard.account')} + {t('dashboard.emails')} - {stats!.top_accounts.map((acc) => { - const accountId = getAccountIdByEmail(acc.key); - return ( - { - if (accountId) { - handleQuickSearch({ account_ids: [accountId] }); - } else { - handleQuickSearch({ to: acc.key }); - } - }} - > - -
-
- - {acc.key} - - - - {formatNumber(acc.count)} - - - ); - })} + {stats!.top_accounts.map((acc) => ( + handleQuickSearch({ account_ids: [getAccountIdByEmail(acc.key) || 0] })}> + {acc.key} + {formatNumber(acc.count)} + + ))}
) : ( @@ -528,7 +442,7 @@ export default function MailArchiveDashboard() {
-
+
© 2025-2026 rustmailer.com - Bichon Email Archiving Project
diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index 9292757..4451a24 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -104,11 +104,15 @@ "totalEmails": "إجمالي رسائل البريد الإلكتروني", "syncedLocally": "تمت مزامنته محليًا", "totalEmailSize": "الحجم الإجمالي لرسائل البريد الإلكتروني", - "logicalVolume": "الحجم المنطقي", + "logicalVolume": "الحجم الأصلي", "localDataFiles": "ملفات البيانات المحلية", "actualDiskUsage": "الاستخدام الفعلي للقرص", + "efficiency": "كفاءة التخزين", + "saved": "الموفرة", + "dataStorage": "تخزين البيانات", + "indexSize": "حجم الفهرس", "metadataSize": "حجم البيانات التعريفية", - "duckdbDatabase": "استناداً إلى تخزين DuckDB", + "tantivyDatabase": "تعتمد على محرك البحث Tantivy", "dayTrend": "اتجاه 30 يومًا", "attachments": "المرفقات", "topLists": "القوائم الأعلى", @@ -195,7 +199,7 @@ "edit": "تعديل", "delete": "حذف", "detail": "التفاصيل", - "syncFolders": "مجلدات المزامنة", + "selectMailboxes": "اختيار صناديق البريد", "oauth2Tokens": "رموز OAuth2", "id": "المعرّف", "enabled": "مفعل", @@ -223,16 +227,15 @@ "accountCreationFailed": "فشل إنشاء الحساب", "updateFailed": "فشل التحديث، يرجى المحاولة مرة أخرى لاحقًا", "creationFailed": "فشل الإنشاء، يرجى المحاولة مرة أخرى لاحقًا", - "selectSyncFolders": "تحديد مجلدات المزامنة", "allMailFolderSelected": "تنبيه: تم تحديد مجلد \"جميع رسائل البريد\"", "accountSyncFoldersUpdated": "تم تحديث مجلدات مزامنة الحساب", "accountSyncFoldersUpdateFailed": "فشل تحديث مجلدات مزامنة الحساب", "serverConfiguration": "تكوين الخادم (IMAP)", - "syncFoldersTitle": "مجلدات المزامنة", + "selectedMailboxes": "صناديق البريد المحددة", "allMailFolderSelectedDesc": "من المحتمل أن يؤدي تحديد المجلدات التي تحمل سمة \"جميع رسائل البريد\" إلى تكرار الرسائل التي تمت مزامنتها بالفعل من مجلدات مثل البريد الوارد والمرسل. قد يستهلك هذا مساحة تخزين أكبر بكثير.", "allMailSkipped": "تم تحديد المجلدات القياسية. تم تخطي 'جميع رسائل البريد' لتجنب التكرارات.", "selectAtLeastOneFolder": "يرجى تحديد مجلد واحد على الأقل", - "chooseFoldersToSync": "اختر المجلدات المراد مزامنتها لـ {{email}}، ستبدأ المجلدات المضافة حديثًا في التنزيل خلال دورة المزامنة التالية.", + "chooseMailboxesToDownload": "اختر صناديق البريد لتنزيلها لـ {{email}}. سيتم تنزيل الصناديق المضافة حديثًا في الدورة التالية.", "accountDetails": "تفاصيل الحساب", "capabilities": "الإمكانيات", "folderLimit": "حد المجلد", @@ -313,23 +316,38 @@ }, "viewDetails": "عرض التفاصيل", "runningState": { - "notStarted": "لم تبدأ", - "inProgress": "قيد التقدم", - "calculating": "جارٍ الحساب...", - "initialSync": "المزامنة الأولية", - "completed": "مكتمل", - "duration": "المدة", - "startTime": "وقت البدء:", - "endTime": "وقت الانتهاء:", - "syncProgress": "تقدم المزامنة", - "incrementalSync": "المزامنة التزايدية", - "syncInterval": "فاصل المزامنة:", - "everyMinutes": "كل {{minutes}} دقيقة", - "errorLogs": "سجلات الأخطاء", - "noErrorLogs": "لا تتوفر سجلات أخطاء.", - "noActiveState": "لا توجد حالة نشطة متاحة", - "accountNotStarted": "قد لا تكون مزامنة الحساب قد بدأت بعد", - "close": "إغلاق" + "message": "رسالة", + "account": { + "id": "معرّف الحساب" + }, + "tabs": { + "active_session": "الجلسة النشطة", + "history": "السجل", + "global_errors": "الأخطاء العامة", + "folders": "صناديق البريد", + "errors": "أخطاء" + }, + "loading": { + "fetching_account_state": "جارٍ تحميل حالة الحساب..." + }, + "empty": { + "no_active_download": "لا يوجد تنزيل نشط", + "no_errors_current": "لا توجد أخطاء في الجلسة الحالية", + "no_errors_session": "لا توجد أخطاء في هذه الجلسة", + "no_global_errors": "لا توجد أخطاء عامة", + "no_history": "لا يوجد سجل" + }, + "session": { + "status": "الحالة", + "trigger": "المشغّل", + "started_at": "وقت البدء" + }, + "common": { + "folders": "صناديق البريد", + "close": "إغلاق" + }, + "latest": "الأحدث", + "folders": "صناديق البريد" }, "steps": { "complete": "مكتمل", diff --git a/web/src/locales/da.json b/web/src/locales/da.json index 65918a2..ddf3fea 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -104,11 +104,15 @@ "totalEmails": "Samlet antal e-mails", "syncedLocally": "Synkroniseret lokalt", "totalEmailSize": "Samlet e-mailstørrelse", - "logicalVolume": "Logisk volumen", + "logicalVolume": "Oprindelig størrelse", "localDataFiles": "Lokale datafiler", "actualDiskUsage": "Faktisk diskforbrug", + "efficiency": "Lagringseffektivitet", + "saved": "Sparet", + "dataStorage": "Datalagring", + "indexSize": "Indeksstørrelse", "metadataSize": "Metadatastørrelse", - "duckdbDatabase": "Baseret på DuckDB-lager", + "tantivyDatabase": "Baseret på Tantivy-søgemaskine", "dayTrend": "30-dages trend", "attachments": "Vedhæftninger", "topLists": "Toplister", @@ -195,7 +199,7 @@ "edit": "Rediger", "delete": "Slet", "detail": "Detaljer", - "syncFolders": "Synkroniser mapper", + "selectMailboxes": "Vælg postkasser", "oauth2Tokens": "OAuth2-tokens", "id": "ID", "enabled": "Aktiveret", @@ -223,16 +227,15 @@ "accountCreationFailed": "Kontooprettelse mislykkedes", "updateFailed": "Opdatering mislykkedes, prøv venligst igen senere", "creationFailed": "Oprettelse mislykkedes, prøv venligst igen senere", - "selectSyncFolders": "Vælg mapper til synkronisering", "allMailFolderSelected": "Bemærk: Mappen \"Al mail\" er valgt", "accountSyncFoldersUpdated": "Synkroniseringsmapper opdateret", "accountSyncFoldersUpdateFailed": "Opdatering af synkroniseringsmapper mislykkedes", "serverConfiguration": "Serverkonfiguration (IMAP)", - "syncFoldersTitle": "Synkroniser mapper", + "selectedMailboxes": "Valgte postkasser", "allMailFolderSelectedDesc": "Valg af mapper med attributtet \"Al mail\" vil sandsynligvis resultere i duplikerede meddelelser, der allerede er synkroniseret fra mapper som Indbakke og Sendt. Dette kan optage betydeligt mere lagerplads.", "allMailSkipped": "Valgte standardmapper. \"Al mail\" blev sprunget over for at undgå dubletter.", "selectAtLeastOneFolder": "Vælg venligst mindst én mappe", - "chooseFoldersToSync": "Vælg mapper til synkronisering for {{email}}. Nytilføjede mapper begynder at downloade ved næste synkroniseringscyklus.", + "chooseMailboxesToDownload": "Vælg postkasser til download for {{email}}. Nye postkasser hentes i næste cyklus.", "accountDetails": "Kontodetaljer", "capabilities": "Funktioner", "folderLimit": "Mappegrænse", @@ -313,23 +316,38 @@ }, "viewDetails": "vis detaljer", "runningState": { - "notStarted": "Ikke startet", - "inProgress": "I gang", - "calculating": "Beregner...", - "initialSync": "Initial synkronisering", - "completed": "Fuldført", - "duration": "Varighed", - "startTime": "Starttid:", - "endTime": "Sluttid:", - "syncProgress": "Synkroniseringsforløb", - "incrementalSync": "Inkrementel synkronisering", - "syncInterval": "Synkroniseringsinterval:", - "everyMinutes": "Hvert {{minutes}} minut", - "errorLogs": "Fejllogs", - "noErrorLogs": "Ingen fejllogs tilgængelige.", - "noActiveState": "Ingen aktiv tilstand tilgængelig", - "accountNotStarted": "Kontosynkronisering er muligvis ikke startet endnu", - "close": "Luk" + "message": "Besked", + "account": { + "id": "Konto-ID" + }, + "tabs": { + "active_session": "Aktiv session", + "history": "Historik", + "global_errors": "Globale fejl", + "folders": "Postkasser", + "errors": "Fejl" + }, + "loading": { + "fetching_account_state": "Henter kontostatus..." + }, + "empty": { + "no_active_download": "Ingen aktiv download", + "no_errors_current": "Ingen fejl i nuværende session", + "no_errors_session": "Ingen fejl i denne session", + "no_global_errors": "Ingen globale fejl", + "no_history": "Ingen historik" + }, + "session": { + "status": "Status", + "trigger": "Trigger", + "started_at": "Starttid" + }, + "common": { + "folders": "postkasser", + "close": "Luk" + }, + "latest": "SENESTE", + "folders": "postkasser" }, "steps": { "complete": "Fuldfør", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index e86a1b5..23d17db 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -104,11 +104,15 @@ "totalEmails": "Gesamt-E-Mails", "syncedLocally": "Lokal synchronisiert", "totalEmailSize": "Gesamtgröße der E-Mails", - "logicalVolume": "Logisches Volumen", + "logicalVolume": "Originalgröße", "localDataFiles": "Lokale Datendateien", "actualDiskUsage": "Tatsächliche Festplattennutzung", + "efficiency": "Speichereffizienz", + "saved": "Eingespart", + "dataStorage": "Datenspeicher", + "indexSize": "Indexgröße", "metadataSize": "Metadatengröße", - "duckdbDatabase": "Basierend auf DuckDB-Speicher", + "tantivyDatabase": "Basiert auf der Tantivy-Suchmaschine", "dayTrend": "30-Tage-Trend", "attachments": "Anhänge", "topLists": "Top-Listen", @@ -195,7 +199,7 @@ "edit": "Bearbeiten", "delete": "Löschen", "detail": "Detail", - "syncFolders": "Zu synchronisierende Ordner", + "selectMailboxes": "Postfächer wählen", "oauth2Tokens": "OAuth2-Token", "id": "ID", "enabled": "Aktiviert", @@ -223,16 +227,15 @@ "accountCreationFailed": "Kontoerstellung fehlgeschlagen", "updateFailed": "Aktualisierung fehlgeschlagen, bitte versuchen Sie es später erneut", "creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es später erneut", - "selectSyncFolders": "Zu synchronisierende Ordner auswählen", "allMailFolderSelected": "Warnung: 'Alle E-Mails'-Ordner ausgewählt", "accountSyncFoldersUpdated": "Synchronisierungsordner des Kontos aktualisiert", "accountSyncFoldersUpdateFailed": "Aktualisierung der Synchronisierungsordner des Kontos fehlgeschlagen", "serverConfiguration": "Serverkonfiguration (IMAP)", - "syncFoldersTitle": "Zu synchronisierende Ordner", + "selectedMailboxes": "Ausgewählte Postfächer", "allMailFolderSelectedDesc": "Die Auswahl von Ordnern mit dem Attribut 'Alle E-Mails' führt wahrscheinlich zur Duplizierung von Nachrichten, die bereits aus Ordnern wie Posteingang und Gesendet synchronisiert wurden. Dies könnte erheblich mehr Speicherplatz verbrauchen.", "allMailSkipped": "Standardordner ausgewählt. 'Alle E-Mails' wurde übersprungen, um Duplikate zu vermeiden.", "selectAtLeastOneFolder": "Wählen Sie mindestens einen Ordner aus", - "chooseFoldersToSync": "Wählen Sie die zu synchronisierenden Ordner für {{email}}. Neu hinzugefügte Ordner beginnen mit dem Download während des nächsten Synchronisierungszyklus.", + "chooseMailboxesToDownload": "Wählen Sie Postfächer zum Herunterladen für {{email}}. Neue Postfächer werden im nächsten Zyklus heruntergeladen.", "accountDetails": "Kontodetails", "capabilities": "Funktionen", "folderLimit": "Ordnerlimit", @@ -313,23 +316,38 @@ }, "viewDetails": "Details anzeigen", "runningState": { - "notStarted": "Nicht gestartet", - "inProgress": "In Bearbeitung", - "calculating": "Wird berechnet...", - "initialSync": "Erste Synchronisierung", - "completed": "Abgeschlossen", - "duration": "Dauer", - "startTime": "Startzeit:", - "endTime": "Endzeit:", - "syncProgress": "Synchronisierungsfortschritt", - "incrementalSync": "Inkrementelle Synchronisierung", - "syncInterval": "Synchronisierungsintervall:", - "everyMinutes": "Alle {{minutes}} Minuten", - "errorLogs": "Fehlerprotokolle", - "noErrorLogs": "Keine Fehlerprotokolle verfügbar.", - "noActiveState": "Kein aktiver Zustand verfügbar", - "accountNotStarted": "Die Kontosynchronisierung hat möglicherweise noch nicht begonnen", - "close": "Schließen" + "message": "Nachricht", + "account": { + "id": "Konto-ID" + }, + "tabs": { + "active_session": "Aktive Sitzung", + "history": "Verlauf", + "global_errors": "Globale Fehler", + "folders": "Postfächer", + "errors": "Fehler" + }, + "loading": { + "fetching_account_state": "Kontostatus wird geladen..." + }, + "empty": { + "no_active_download": "Kein aktiver Download", + "no_errors_current": "Keine Fehler in der aktuellen Sitzung", + "no_errors_session": "Keine Fehler in dieser Sitzung", + "no_global_errors": "Keine globalen Fehler", + "no_history": "Kein Verlauf vorhanden" + }, + "session": { + "status": "Status", + "trigger": "Auslöser", + "started_at": "Startzeit" + }, + "common": { + "folders": "Postfächer", + "close": "Schließen" + }, + "latest": "NEU", + "folders": "Postfächer" }, "steps": { "complete": "Abgeschlossen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 851a5c0..d32f524 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -104,11 +104,15 @@ "totalEmails": "Total Emails", "syncedLocally": "Synced locally", "totalEmailSize": "Total Email Size", - "logicalVolume": "Logical volume", + "logicalVolume": "Original Size", "localDataFiles": "Local Data Files", "actualDiskUsage": "Actual disk usage", + "efficiency": "Efficiency", + "saved": "Saved", + "dataStorage": "Storage", + "indexSize": "Index", "metadataSize": "Metadata Size", - "duckdbDatabase": "Based on DuckDB Storage", + "tantivyDatabase": "Based on Tantivy Search Engine", "dayTrend": "30-Day Trend", "attachments": "Attachments", "topLists": "Top Lists", @@ -195,7 +199,7 @@ "edit": "Edit", "delete": "Delete", "detail": "Detail", - "syncFolders": "Sync Folders", + "selectMailboxes": "Choose Mailboxes", "oauth2Tokens": "OAuth2 Tokens", "id": "ID", "enabled": "Enabled", @@ -223,16 +227,15 @@ "accountCreationFailed": "Account Creation Failed", "updateFailed": "Update failed, please try again later", "creationFailed": "Creation failed, please try again later", - "selectSyncFolders": "Select Sync Folders", "allMailFolderSelected": "Heads Up: \"All Mail\" Folder Selected", "accountSyncFoldersUpdated": "Account Sync Folders Updated", "accountSyncFoldersUpdateFailed": "Account Sync Folders Update Failed", "serverConfiguration": "Server Configuration (IMAP)", - "syncFoldersTitle": "Sync Folders", + "selectedMailboxes": "Selected Mailboxes", "allMailFolderSelectedDesc": "Selecting folders with the \"All Mail\" attribute will likely lead to duplicating messages already synced from folders like Inbox and Sent. This may consume significantly more storage space.", "allMailSkipped": "Selected standard folders. 'All Mail' was skipped to avoid duplicates.", "selectAtLeastOneFolder": "Please select at least one folder", - "chooseFoldersToSync": "Choose folders to sync for {{email}}, Newly added folders will begin downloading during the next sync cycle.", + "chooseMailboxesToDownload": "Choose mailboxes to download for {{email}}. Newly added mailboxes will be downloaded in the next cycle.", "accountDetails": "Account Details", "capabilities": "Capabilities", "folderLimit": "Folder Limit", @@ -313,23 +316,38 @@ }, "viewDetails": "view details", "runningState": { - "notStarted": "Not Started", - "inProgress": "In Progress", - "calculating": "Calculating...", - "initialSync": "Initial Sync", - "completed": "Completed", - "duration": "Duration", - "startTime": "Start Time:", - "endTime": "End Time:", - "syncProgress": "Sync Progress", - "incrementalSync": "Incremental Sync", - "syncInterval": "Sync Interval:", - "everyMinutes": "Every {{minutes}} minutes", - "errorLogs": "Error Logs", - "noErrorLogs": "No error logs available.", - "noActiveState": "No active state available", - "accountNotStarted": "Account synchronization may not have started yet", - "close": "Close" + "message": "Message", + "account": { + "id": "Account ID" + }, + "tabs": { + "active_session": "Active Session", + "history": "History", + "global_errors": "Global Errors", + "folders": "Mailboxes", + "errors": "Errors" + }, + "loading": { + "fetching_account_state": "Fetching account state..." + }, + "empty": { + "no_active_download": "No download in progress", + "no_errors_current": "No errors in current session", + "no_errors_session": "No errors in this session", + "no_global_errors": "No global errors", + "no_history": "No historical records found" + }, + "session": { + "status": "Status", + "trigger": "Trigger", + "started_at": "Started At" + }, + "common": { + "folders": "mailboxes", + "close": "Close" + }, + "latest": "LATEST", + "folders": "mailboxes" }, "steps": { "complete": "Complete", diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 6cd88ca..f06851c 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -104,11 +104,15 @@ "totalEmails": "Total de correos", "syncedLocally": "Sincronizado localmente", "totalEmailSize": "Tamaño total de correos", - "logicalVolume": "Volumen lógico", + "logicalVolume": "Tamaño original", "localDataFiles": "Archivos de datos locales", "actualDiskUsage": "Uso real de disco", + "efficiency": "Eficiencia", + "saved": "Ahorrado", + "dataStorage": "Almacenamiento", + "indexSize": "Tamaño índice", "metadataSize": "Tamaño de metadatos", - "duckdbDatabase": "Basado en el almacenamiento DuckDB", + "tantivyDatabase": "Basado en el motor de búsqueda Tantivy", "dayTrend": "Tendencia de 30 días", "attachments": "Archivos adjuntos", "topLists": "Listas principales", @@ -195,7 +199,7 @@ "edit": "Editar", "delete": "Eliminar", "detail": "Detalle", - "syncFolders": "Carpetas a sincronizar", + "selectMailboxes": "Elegir buzones", "oauth2Tokens": "Tokens OAuth2", "id": "ID", "enabled": "Habilitado", @@ -223,16 +227,15 @@ "accountCreationFailed": "Error al crear la cuenta", "updateFailed": "Error al actualizar, por favor, inténtalo de nuevo más tarde", "creationFailed": "Error al crear, por favor, inténtalo de nuevo más tarde", - "selectSyncFolders": "Selecciona carpetas a sincronizar", "allMailFolderSelected": "Advertencia: Carpeta 'Todo el correo' seleccionada", "accountSyncFoldersUpdated": "Carpetas de sincronización de cuenta actualizadas", "accountSyncFoldersUpdateFailed": "Error al actualizar las carpetas de sincronización de cuenta", "serverConfiguration": "Configuración del servidor (IMAP)", - "syncFoldersTitle": "Carpetas a sincronizar", + "selectedMailboxes": "Buzones seleccionados", "allMailFolderSelectedDesc": "Seleccionar carpetas con el atributo 'Todo el correo' probablemente resultará en la duplicación de mensajes ya sincronizados de carpetas como Bandeja de entrada y Enviados. Esto podría consumir significativamente más espacio de almacenamiento.", "allMailSkipped": "Carpetas predeterminadas seleccionadas. 'Todo el correo' omitido para evitar duplicados.", "selectAtLeastOneFolder": "Selecciona al menos una carpeta", - "chooseFoldersToSync": "Elige las carpetas a sincronizar para {{email}}. Las carpetas añadidas recientemente comenzarán a descargarse durante el próximo ciclo de sincronización.", + "chooseMailboxesToDownload": "Elige los buzones a descargar para {{email}}. Los nuevos buzones se descargarán en el próximo ciclo.", "accountDetails": "Detalles de la cuenta", "capabilities": "Capacidades", "folderLimit": "Límite de carpetas", @@ -313,23 +316,38 @@ }, "viewDetails": "ver detalles", "runningState": { - "notStarted": "No iniciado", - "inProgress": "En curso", - "calculating": "Calculando...", - "initialSync": "Sincronización inicial", - "completed": "Completado", - "duration": "Duración", - "startTime": "Hora de inicio:", - "endTime": "Hora de finalización:", - "syncProgress": "Progreso de sincronización", - "incrementalSync": "Sincronización incremental", - "syncInterval": "Intervalo de sincronización:", - "everyMinutes": "Cada {{minutes}} minutos", - "errorLogs": "Registros de errores", - "noErrorLogs": "No hay registros de errores disponibles.", - "noActiveState": "No hay estado activo disponible", - "accountNotStarted": "La sincronización de la cuenta podría no haber comenzado todavía", - "close": "Cerrar" + "message": "Mensaje", + "account": { + "id": "ID de cuenta" + }, + "tabs": { + "active_session": "Sesión activa", + "history": "Historial", + "global_errors": "Errores globales", + "folders": "Buzones", + "errors": "Errores" + }, + "loading": { + "fetching_account_state": "Obteniendo estado de la cuenta..." + }, + "empty": { + "no_active_download": "No hay descargas en curso", + "no_errors_current": "Sin errores en la sesión actual", + "no_errors_session": "Sin errores en esta sesión", + "no_global_errors": "Sin errores globales", + "no_history": "Sin historial" + }, + "session": { + "status": "Estado", + "trigger": "Disparador", + "started_at": "Hora de inicio" + }, + "common": { + "folders": "buzones", + "close": "Cerrar" + }, + "latest": "RECIENTE", + "folders": "buzones" }, "steps": { "complete": "Completado", diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index 603289a..a804b6a 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -104,11 +104,15 @@ "totalEmails": "Sähköpostien kokonaismäärä", "syncedLocally": "Synkronoitu paikallisesti", "totalEmailSize": "Sähköpostien kokonaiskoko", - "logicalVolume": "Looginen tilavuus", + "logicalVolume": "Alkuperäinen koko", "localDataFiles": "Paikalliset datatiedostot", "actualDiskUsage": "Todellinen levynkäyttö", + "efficiency": "Tallennustehokkuus", + "saved": "Säästetty", + "dataStorage": "Tallennustila", + "indexSize": "Indeksikoko", "metadataSize": "Metatietojen koko", - "duckdbDatabase": "Perustuu DuckDB-tallennustilaan", + "tantivyDatabase": "Perustuu Tantivy-hakukoneeseen", "dayTrend": "30 päivän trendi", "attachments": "Liitteet", "topLists": "Parhaat listat", @@ -195,7 +199,7 @@ "edit": "Muokkaa", "delete": "Poista", "detail": "Tiedot", - "syncFolders": "Synkronoi kansiot", + "selectMailboxes": "Valitse postilaatikot", "oauth2Tokens": "OAuth2-tunnukset", "id": "ID", "enabled": "Käytössä", @@ -223,16 +227,15 @@ "accountCreationFailed": "Tilin luominen epäonnistui", "updateFailed": "Päivitys epäonnistui, yritä myöhemmin uudelleen", "creationFailed": "Luominen epäonnistui, yritä myöhemmin uudelleen", - "selectSyncFolders": "Valitse synkronoitavat kansiot", "allMailFolderSelected": "Varoitus: 'Kaikki sähköpostit' -kansio valittu", "accountSyncFoldersUpdated": "Tilin synkronointikansiot päivitetty", "accountSyncFoldersUpdateFailed": "Tilin synkronointikansioiden päivitys epäonnistui", "serverConfiguration": "Palvelinmääritys (IMAP)", - "syncFoldersTitle": "Synkronoi kansiot", + "selectedMailboxes": "Valitut postilaatikot", "allMailFolderSelectedDesc": "Kansioiden valitseminen, joilla on 'Kaikki sähköpostit' -attribuutti, johtaa todennäköisesti jo synkronoitujen viestien kahdentumiseen kansioista, kuten Saapuneet ja Lähetetyt. Tämä voi kuluttaa huomattavasti enemmän tallennustilaa.", "allMailSkipped": "Oletuskansiot valittu. 'Kaikki sähköpostit' ohitettiin päällekkäisyyksien välttämiseksi.", "selectAtLeastOneFolder": "Valitse vähintään yksi kansio", - "chooseFoldersToSync": "Valitse synkronoitavat kansiot {{email}}. Äskettäin lisätyt kansiot alkavat latautua seuraavan synkronointisyklin aikana.", + "chooseMailboxesToDownload": "Valitse ladattavat postilaatikot käyttäjälle {{email}}. Uudet postilaatikot ladataan seuraavassa kierrossa.", "accountDetails": "Tilin tiedot", "capabilities": "Ominaisuudet", "folderLimit": "Kansioraja", @@ -313,23 +316,38 @@ }, "viewDetails": "katso tiedot", "runningState": { - "notStarted": "Ei aloitettu", - "inProgress": "Käynnissä", - "calculating": "Lasketaan...", - "initialSync": "Alkusynkronointi", - "completed": "Valmis", - "duration": "Kesto", - "startTime": "Aloitusaika:", - "endTime": "Päättymisaika:", - "syncProgress": "Synkronoinnin edistyminen", - "incrementalSync": "Lisäävä synkronointi", - "syncInterval": "Synkronointiväli:", - "everyMinutes": "Joka {{minutes}} minuutti", - "errorLogs": "Virhelokit", - "noErrorLogs": "Virhelokeja ei saatavilla.", - "noActiveState": "Ei aktiivista tilaa saatavilla", - "accountNotStarted": "Tilin synkronointi ei ehkä ole vielä alkanut", - "close": "Sulje" + "message": "Viesti", + "account": { + "id": "Tilin ID" + }, + "tabs": { + "active_session": "Aktiivinen istunto", + "history": "Historia", + "global_errors": "Yleiset virheet", + "folders": "Postilaatikot", + "errors": "Virheet" + }, + "loading": { + "fetching_account_state": "Haetaan tilan tietoja..." + }, + "empty": { + "no_active_download": "Ei aktiivista latausta", + "no_errors_current": "Ei virheitä nykyisessä istunnossa", + "no_errors_session": "Ei virheitä tässä istunnossa", + "no_global_errors": "Ei yleisiä virheitä", + "no_history": "Ei historiaa" + }, + "session": { + "status": "Tila", + "trigger": "Laukaisija", + "started_at": "Aloitusaika" + }, + "common": { + "folders": "postilaatikot", + "close": "Sulje" + }, + "latest": "UUSIN", + "folders": "postilaatikot" }, "steps": { "complete": "Valmis", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index a565b01..5617347 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -104,11 +104,15 @@ "totalEmails": "Total des e-mails", "syncedLocally": "Synchronisés localement", "totalEmailSize": "Taille totale des e-mails", - "logicalVolume": "Volume logique", + "logicalVolume": "Taille originale", "localDataFiles": "Fichiers de données locaux", "actualDiskUsage": "Utilisation réelle du disque", + "efficiency": "Efficacité", + "saved": "Économisé", + "dataStorage": "Stockage", + "indexSize": "Taille d'index", "metadataSize": "Taille des métadonnées", - "duckdbDatabase": "Basé sur le stockage DuckDB", + "tantivyDatabase": "Basé sur le moteur de recherche Tantivy", "dayTrend": "Tendance sur 30 jours", "attachments": "Pièces jointes", "topLists": "Top Listes", @@ -195,7 +199,7 @@ "edit": "Modifier", "delete": "Supprimer", "detail": "Détail", - "syncFolders": "Synchroniser les Dossiers", + "selectMailboxes": "Choisir les boîtes mail", "oauth2Tokens": "Jetons OAuth2", "id": "ID", "enabled": "Activé", @@ -223,16 +227,15 @@ "accountCreationFailed": "Échec de la Création du Compte", "updateFailed": "La mise à jour a échoué, veuillez réessayer plus tard", "creationFailed": "La création a échoué, veuillez réessayer plus tard", - "selectSyncFolders": "Sélectionner les Dossiers de Synchronisation", "allMailFolderSelected": "Attention : Dossier 'Tous les messages' sélectionné", "accountSyncFoldersUpdated": "Dossiers de Synchronisation de Compte Mis à Jour", "accountSyncFoldersUpdateFailed": "Échec de la Mise à Jour des Dossiers de Synchronisation de Compte", "serverConfiguration": "Configuration du Serveur (IMAP)", - "syncFoldersTitle": "Synchroniser les Dossiers", + "selectedMailboxes": "Boîtes mail sélectionnées", "allMailFolderSelectedDesc": "La sélection de dossiers avec l'attribut 'Tous les messages' entraînera probablement la duplication des messages déjà synchronisés à partir de dossiers tels que Boîte de réception et Éléments envoyés. Cela peut consommer beaucoup plus d'espace de stockage.", "allMailSkipped": "Dossiers par défaut sélectionnés. 'Tous les messages' a été ignoré pour éviter les doublons.", "selectAtLeastOneFolder": "Veuillez sélectionner au moins un dossier", - "chooseFoldersToSync": "Choisissez les dossiers à synchroniser pour {{email}}. Les dossiers nouvellement ajoutés commenceront à être téléchargés lors du prochain cycle de synchronisation.", + "chooseMailboxesToDownload": "Choisissez les boîtes mail à télécharger pour {{email}}. Les nouvelles boîtes seront téléchargées au prochain cycle.", "accountDetails": "Détails du Compte", "capabilities": "Capacités", "folderLimit": "Limite de Dossiers", @@ -313,23 +316,38 @@ }, "viewDetails": "voir les détails", "runningState": { - "notStarted": "Non Démarré", - "inProgress": "En Cours", - "calculating": "Calcul en cours...", - "initialSync": "Synchronisation Initiale", - "completed": "Terminé", - "duration": "Durée", - "startTime": "Heure de Début :", - "endTime": "Heure de Fin :", - "syncProgress": "Progression de la Synchronisation", - "incrementalSync": "Synchronisation Incrémentielle", - "syncInterval": "Intervalle de Synchronisation :", - "everyMinutes": "Toutes les {{minutes}} minutes", - "errorLogs": "Journaux d'Erreurs", - "noErrorLogs": "Aucun journal d'erreurs disponible.", - "noActiveState": "Aucun état actif disponible", - "accountNotStarted": "La synchronisation du compte n'a peut-être pas encore démarré", - "close": "Fermer" + "message": "Message", + "account": { + "id": "ID du compte" + }, + "tabs": { + "active_session": "Session active", + "history": "Historique", + "global_errors": "Erreurs globales", + "folders": "Boîtes mail", + "errors": "Erreurs" + }, + "loading": { + "fetching_account_state": "Chargement de l'état du compte..." + }, + "empty": { + "no_active_download": "Aucun téléchargement en cours", + "no_errors_current": "Aucune erreur dans la session actuelle", + "no_errors_session": "Aucune erreur dans cette session", + "no_global_errors": "Aucune erreur globale", + "no_history": "Aucun historique disponible" + }, + "session": { + "status": "Statut", + "trigger": "Déclencheur", + "started_at": "Heure de début" + }, + "common": { + "folders": "boîtes mail", + "close": "Fermer" + }, + "latest": "RÉCENT", + "folders": "boîtes mail" }, "steps": { "complete": "Terminé", diff --git a/web/src/locales/it.json b/web/src/locales/it.json index ef1557e..05bef55 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -104,11 +104,15 @@ "totalEmails": "Email totali", "syncedLocally": "Sincronizzate localmente", "totalEmailSize": "Dimensione totale email", - "logicalVolume": "Volume logico", + "logicalVolume": "Dimensioni originali", "localDataFiles": "File di dati locali", "actualDiskUsage": "Uso effettivo del disco", + "efficiency": "Efficienza", + "saved": "Risparmiato", + "dataStorage": "Archiviazione", + "indexSize": "Dimens. indice", "metadataSize": "Dimensione metadati", - "duckdbDatabase": "Basato sull'archiviazione DuckDB", + "tantivyDatabase": "Basato sul motore di ricerca Tantivy", "dayTrend": "Tendenza a 30 giorni", "attachments": "Allegati", "topLists": "Liste Top", @@ -195,7 +199,7 @@ "edit": "Modifica", "delete": "Elimina", "detail": "Dettaglio", - "syncFolders": "Sincronizza Cartelle", + "selectMailboxes": "Scegli caselle", "oauth2Tokens": "Token OAuth2", "id": "ID", "enabled": "Abilitato", @@ -223,16 +227,15 @@ "accountCreationFailed": "Creazione Account Fallita", "updateFailed": "Aggiornamento fallito, riprova più tardi", "creationFailed": "Creazione fallita, riprova più tardi", - "selectSyncFolders": "Seleziona Cartelle di Sincronizzazione", "allMailFolderSelected": "Attenzione: Cartella 'Tutta la Posta' Selezionata", "accountSyncFoldersUpdated": "Cartelle di Sincronizzazione Account Aggiornate", "accountSyncFoldersUpdateFailed": "Aggiornamento Cartelle di Sincronizzazione Account Fallito", "serverConfiguration": "Configurazione Server (IMAP)", - "syncFoldersTitle": "Sincronizza Cartelle", + "selectedMailboxes": "Caselle selezionate", "allMailFolderSelectedDesc": "La selezione di cartelle con l'attributo 'Tutta la Posta' probabilmente causerà la duplicazione dei messaggi già sincronizzati da cartelle come Posta in arrivo e Posta inviata. Questo può consumare significativamente più spazio di archiviazione.", "allMailSkipped": "Cartelle predefinite selezionate. 'Tutta la Posta' è stata saltata per evitare duplicati.", "selectAtLeastOneFolder": "Seleziona almeno una cartella", - "chooseFoldersToSync": "Scegli le cartelle da sincronizzare per {{email}}. Le cartelle appena aggiunte inizieranno a essere scaricate durante il prossimo ciclo di sincronizzazione.", + "chooseMailboxesToDownload": "Scegli le caselle da scaricare per {{email}}. Le nuove caselle verranno scaricate nel prossimo ciclo.", "accountDetails": "Dettagli Account", "capabilities": "Capacità", "folderLimit": "Limite Cartelle", @@ -313,23 +316,38 @@ }, "viewDetails": "visualizza dettagli", "runningState": { - "notStarted": "Non Avviato", - "inProgress": "In Corso", - "calculating": "Calcolo in corso...", - "initialSync": "Sincronizzazione Iniziale", - "completed": "Completato", - "duration": "Durata", - "startTime": "Ora di Inizio:", - "endTime": "Ora di Fine:", - "syncProgress": "Avanzamento Sincronizzazione", - "incrementalSync": "Sincronizzazione Incrementale", - "syncInterval": "Intervallo di Sincronizzazione:", - "everyMinutes": "Ogni {{minutes}} minuti", - "errorLogs": "Log Errori", - "noErrorLogs": "Nessun log errori disponibile.", - "noActiveState": "Nessuno stato attivo disponibile", - "accountNotStarted": "La sincronizzazione dell'account potrebbe non essere ancora iniziata", - "close": "Chiudi" + "message": "Messaggio", + "account": { + "id": "ID account" + }, + "tabs": { + "active_session": "Sessione attiva", + "history": "Cronologia", + "global_errors": "Errori globali", + "folders": "Caselle di posta", + "errors": "Errori" + }, + "loading": { + "fetching_account_state": "Caricamento stato account..." + }, + "empty": { + "no_active_download": "Nessun download in corso", + "no_errors_current": "Nessun errore nella sessione corrente", + "no_errors_session": "Nessun errore in questa sessione", + "no_global_errors": "Nessun errore globale", + "no_history": "Nessuna cronologia disponibile" + }, + "session": { + "status": "Stato", + "trigger": "Trigger", + "started_at": "Ora di inizio" + }, + "common": { + "folders": "caselle di posta", + "close": "Chiudi" + }, + "latest": "RECENTE", + "folders": "caselle di posta" }, "steps": { "complete": "Completato", diff --git a/web/src/locales/jp.json b/web/src/locales/jp.json index 61ef598..1aabbed 100644 --- a/web/src/locales/jp.json +++ b/web/src/locales/jp.json @@ -104,11 +104,15 @@ "totalEmails": "合計メール数", "syncedLocally": "ローカル同期済み", "totalEmailSize": "メール総容量", - "logicalVolume": "論理ボリューム", + "logicalVolume": "元のサイズ", "localDataFiles": "ローカルデータファイル", "actualDiskUsage": "実際のディスク使用量", + "efficiency": "圧縮効率", + "saved": "節約済み", + "dataStorage": "データストレージ", + "indexSize": "インデックスサイズ", "metadataSize": "メタデータサイズ", - "duckdbDatabase": "DuckDBストレージベース", + "tantivyDatabase": "Tantivy 検索エンジンを搭載", "dayTrend": "30日間トレンド", "attachments": "添付ファイル", "topLists": "トップリスト", @@ -195,7 +199,7 @@ "edit": "編集", "delete": "削除", "detail": "詳細", - "syncFolders": "同期フォルダー", + "selectMailboxes": "メールボックスを選択", "oauth2Tokens": "OAuth2トークン", "id": "ID", "enabled": "有効", @@ -223,16 +227,15 @@ "accountCreationFailed": "アカウントの作成に失敗しました", "updateFailed": "更新に失敗しました。しばらくしてからもう一度お試しください。", "creationFailed": "作成に失敗しました。しばらくしてからもう一度お試しください。", - "selectSyncFolders": "同期フォルダーを選択", "allMailFolderSelected": "ご注意: 「すべてのメール」フォルダーが選択されています", "accountSyncFoldersUpdated": "アカウント同期フォルダーが更新されました", "accountSyncFoldersUpdateFailed": "アカウント同期フォルダーの更新に失敗しました", "serverConfiguration": "サーバー設定 (IMAP)", - "syncFoldersTitle": "同期フォルダー", + "selectedMailboxes": "選択されたメールボックス", "allMailFolderSelectedDesc": "「すべてのメール」属性を持つフォルダーを選択すると、受信トレイや送信済みなどのフォルダーからすでに同期されているメッセージが重複する可能性があります。これにより、ストレージ容量が大幅に消費される可能性があります。", "allMailSkipped": "標準フォルダーが選択されました。「すべてのメール」は重複を避けるためにスキップされました。", "selectAtLeastOneFolder": "少なくとも1つのフォルダーを選択してください", - "chooseFoldersToSync": "{{email}}の同期フォルダーを選択してください。新たに追加されたフォルダーは、次回の同期サイクルでダウンロードが開始されます。", + "chooseMailboxesToDownload": "{{email}} のダウンロード対象メールボックスを選択してください。新しく追加されたメールボックスは次回のサイクルでダウンロードされます。", "accountDetails": "アカウント詳細", "capabilities": "機能", "folderLimit": "フォルダーの制限", @@ -313,23 +316,38 @@ }, "viewDetails": "詳細を見る", "runningState": { - "notStarted": "未開始", - "inProgress": "進行中", - "calculating": "計算中...", - "initialSync": "初期同期", - "completed": "完了", - "duration": "期間", - "startTime": "開始時刻:", - "endTime": "終了時刻:", - "syncProgress": "同期進捗", - "incrementalSync": "増分同期", - "syncInterval": "同期間隔:", - "everyMinutes": "{{minutes}}分ごと", - "errorLogs": "エラーログ", - "noErrorLogs": "エラーログはありません。", - "noActiveState": "アクティブな状態はありません", - "accountNotStarted": "アカウント同期がまだ開始されていない可能性があります", - "close": "閉じる" + "message": "メッセージ", + "account": { + "id": "アカウントID" + }, + "tabs": { + "active_session": "実行中タスク", + "history": "履歴", + "global_errors": "全体エラー", + "folders": "メールボックス", + "errors": "エラー" + }, + "loading": { + "fetching_account_state": "アカウント状態を取得中..." + }, + "empty": { + "no_active_download": "現在ダウンロード中のタスクはありません", + "no_errors_current": "現在のタスクにエラーはありません", + "no_errors_session": "このタスクにエラーはありません", + "no_global_errors": "全体エラーはありません", + "no_history": "履歴がありません" + }, + "session": { + "status": "状態", + "trigger": "トリガー", + "started_at": "開始時刻" + }, + "common": { + "folders": "メールボックス", + "close": "閉じる" + }, + "latest": "最新", + "folders": "メールボックス" }, "steps": { "complete": "完了", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index f62e79b..179fb05 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -104,11 +104,15 @@ "totalEmails": "총 이메일 수", "syncedLocally": "로컬 동기화됨", "totalEmailSize": "총 이메일 크기", - "logicalVolume": "논리 볼륨", + "logicalVolume": "원래 크기", "localDataFiles": "로컬 데이터 파일", "actualDiskUsage": "실제 디스크 사용량", + "efficiency": "압축 효율", + "saved": "절약됨", + "dataStorage": "데이터 스토리지", + "indexSize": "인덱스 크기", "metadataSize": "메타데이터 크기", - "duckdbDatabase": "DuckDB 스토리지 기반", + "tantivyDatabase": "Tantivy 검색 엔진 기반", "dayTrend": "30일 추세", "attachments": "첨부 파일", "topLists": "상위 목록", @@ -195,7 +199,7 @@ "edit": "편집", "delete": "삭제", "detail": "세부 정보", - "syncFolders": "동기화 폴더", + "selectMailboxes": "메일함 선택", "oauth2Tokens": "OAuth2 토큰", "id": "ID", "enabled": "활성화됨", @@ -223,16 +227,15 @@ "accountCreationFailed": "계정 생성 실패", "updateFailed": "업데이트에 실패했습니다. 나중에 다시 시도하십시오.", "creationFailed": "생성에 실패했습니다. 나중에 다시 시도하십시오.", - "selectSyncFolders": "동기화 폴더 선택", "allMailFolderSelected": "주의: '모든 메일' 폴더가 선택되었습니다", "accountSyncFoldersUpdated": "계정 동기화 폴더 업데이트됨", "accountSyncFoldersUpdateFailed": "계정 동기화 폴더 업데이트 실패", "serverConfiguration": "서버 구성 (IMAP)", - "syncFoldersTitle": "동기화 폴더", + "selectedMailboxes": "선택된 메일함", "allMailFolderSelectedDesc": "'모든 메일' 속성을 가진 폴더를 선택하면 받은 편지함이나 보낸 항목과 같은 폴더에서 이미 동기화된 메시지가 중복될 수 있습니다. 이로 인해 저장 공간이 상당히 많이 사용될 수 있습니다.", "allMailSkipped": "기본 폴더가 선택되었습니다. 중복을 방지하기 위해 '모든 메일'은 건너뛰었습니다.", "selectAtLeastOneFolder": "하나 이상의 폴더를 선택하십시오", - "chooseFoldersToSync": "{{email}}에 대해 동기화할 폴더를 선택하십시오. 새로 추가된 폴더는 다음 동기화 주기부터 다운로드되기 시작합니다.", + "chooseMailboxesToDownload": "{{email}}에 대해 다운로드할 메일함을 선택하세요. 새로 추가된 메일함은 다음 주기에서 다운로드됩니다.", "accountDetails": "계정 세부 정보", "capabilities": "기능", "folderLimit": "폴더 제한", @@ -313,23 +316,38 @@ }, "viewDetails": "세부 정보 보기", "runningState": { - "notStarted": "시작되지 않음", - "inProgress": "진행 중", - "calculating": "계산 중...", - "initialSync": "초기 동기화", - "completed": "완료됨", - "duration": "기간", - "startTime": "시작 시간:", - "endTime": "종료 시간:", - "syncProgress": "동기화 진행률", - "incrementalSync": "증분 동기화", - "syncInterval": "동기화 간격:", - "everyMinutes": "매 {{minutes}}분", - "errorLogs": "오류 로그", - "noErrorLogs": "오류 로그가 없습니다.", - "noActiveState": "활성 상태 없음", - "accountNotStarted": "계정 동기화가 아직 시작되지 않았을 수 있습니다", - "close": "닫기" + "message": "메시지", + "account": { + "id": "계정 ID" + }, + "tabs": { + "active_session": "현재 작업", + "history": "기록", + "global_errors": "전체 오류", + "folders": "메일함", + "errors": "오류" + }, + "loading": { + "fetching_account_state": "계정 상태를 불러오는 중..." + }, + "empty": { + "no_active_download": "진행 중인 다운로드가 없습니다", + "no_errors_current": "현재 작업에 오류가 없습니다", + "no_errors_session": "이 작업에 오류가 없습니다", + "no_global_errors": "전체 오류가 없습니다", + "no_history": "기록이 없습니다" + }, + "session": { + "status": "상태", + "trigger": "트리거", + "started_at": "시작 시간" + }, + "common": { + "folders": "메일함", + "close": "닫기" + }, + "latest": "최신", + "folders": "메일함" }, "steps": { "complete": "완료", diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index 692b829..a91c087 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -104,11 +104,15 @@ "totalEmails": "Totaal aantal e-mails", "syncedLocally": "Lokaal gesynchroniseerd", "totalEmailSize": "Totale e-mailgrootte", - "logicalVolume": "Logisch volume", + "logicalVolume": "Oorspronkelijke grootte", "localDataFiles": "Lokale Gegevensbestanden", "actualDiskUsage": "Feitelijk schijfgebruik", + "efficiency": "Opslagefficiëntie", + "saved": "Bespaard", + "dataStorage": "Gegevensopslag", + "indexSize": "Indexgrootte", "metadataSize": "Metadata grootte", - "duckdbDatabase": "Gebaseerd op DuckDB-opslag", + "tantivyDatabase": "Gebaseerd op de zoekmachine Tantivy", "dayTrend": "30-Dagen Trend", "attachments": "Bijlagen", "topLists": "Toplijsten", @@ -195,7 +199,7 @@ "edit": "Bewerken", "delete": "Verwijderen", "detail": "Detail", - "syncFolders": "Sync Mappen", + "selectMailboxes": "Mailboxen kiezen", "oauth2Tokens": "OAuth2 Tokens", "id": "ID", "enabled": "Ingeschakeld", @@ -223,16 +227,15 @@ "accountCreationFailed": "Account Aanmaken Mislukt", "updateFailed": "Bijwerken mislukt, probeer het later opnieuw", "creationFailed": "Aanmaken mislukt, probeer het later opnieuw", - "selectSyncFolders": "Selecteer Sync Mappen", "allMailFolderSelected": "Let Op: 'Alle Mail' Map Geselecteerd", "accountSyncFoldersUpdated": "Account Sync Mappen Bijgewerkt", "accountSyncFoldersUpdateFailed": "Account Sync Mappen Bijwerken Mislukt", "serverConfiguration": "Serverconfiguratie (IMAP)", - "syncFoldersTitle": "Sync Mappen", + "selectedMailboxes": "Geselecteerde mailboxen", "allMailFolderSelectedDesc": "Het selecteren van mappen met het 'Alle Mail'-kenmerk zal waarschijnlijk leiden tot dubbele berichten die al zijn gesynchroniseerd vanuit mappen zoals Postvak In en Verzonden. Dit kan aanzienlijk meer opslagruimte in beslag nemen.", "allMailSkipped": "Standaardmappen geselecteerd. 'Alle Mail' is overgeslagen om duplicaten te voorkomen.", "selectAtLeastOneFolder": "Selecteer alstublieft ten minste één map", - "chooseFoldersToSync": "Kies mappen om te synchroniseren voor {{email}}. Nieuw toegevoegde mappen beginnen met downloaden tijdens de volgende synchronisatiecyclus.", + "chooseMailboxesToDownload": "Kies mailboxen om te downloaden voor {{email}}. Nieuw toegevoegde mailboxen worden in de volgende cyclus gedownload.", "accountDetails": "Accountdetails", "capabilities": "Mogelijkheden", "folderLimit": "Mappenlimiet", @@ -313,23 +316,38 @@ }, "viewDetails": "details bekijken", "runningState": { - "notStarted": "Niet Gestart", - "inProgress": "Bezig", - "calculating": "Bezig met berekenen...", - "initialSync": "Initiële Sync", - "completed": "Voltooid", - "duration": "Duur", - "startTime": "Starttijd:", - "endTime": "Eindtijd:", - "syncProgress": "Sync Voortgang", - "incrementalSync": "Incrementale Sync", - "syncInterval": "Sync Interval:", - "everyMinutes": "Elke {{minutes}} minuten", - "errorLogs": "Foutlogboeken", - "noErrorLogs": "Geen foutlogboeken beschikbaar.", - "noActiveState": "Geen actieve status beschikbaar", - "accountNotStarted": "Accountsynchronisatie is mogelijk nog niet gestart", - "close": "Sluiten" + "message": "Bericht", + "account": { + "id": "Account-ID" + }, + "tabs": { + "active_session": "Actieve sessie", + "history": "Geschiedenis", + "global_errors": "Globale fouten", + "folders": "Mailboxen", + "errors": "Fouten" + }, + "loading": { + "fetching_account_state": "Accountstatus ophalen..." + }, + "empty": { + "no_active_download": "Geen actieve download", + "no_errors_current": "Geen fouten in huidige sessie", + "no_errors_session": "Geen fouten in deze sessie", + "no_global_errors": "Geen globale fouten", + "no_history": "Geen geschiedenis beschikbaar" + }, + "session": { + "status": "Status", + "trigger": "Trigger", + "started_at": "Starttijd" + }, + "common": { + "folders": "mailboxen", + "close": "Sluiten" + }, + "latest": "RECENT", + "folders": "mailboxen" }, "steps": { "complete": "Voltooid", diff --git a/web/src/locales/no.json b/web/src/locales/no.json index c48d8bf..23c3d19 100644 --- a/web/src/locales/no.json +++ b/web/src/locales/no.json @@ -104,11 +104,15 @@ "totalEmails": "Totalt antall e-poster", "syncedLocally": "Synkronisert lokalt", "totalEmailSize": "Total e-poststørrelse", - "logicalVolume": "Logisk volum", + "logicalVolume": "Opprinnelig størrelse", "localDataFiles": "Lokale datafiler", "actualDiskUsage": "Faktisk diskbruk", + "efficiency": "Lagringseffektivitet", + "saved": "Spart", + "dataStorage": "Datalagring", + "indexSize": "Indeksstørrelse", "metadataSize": "Metadatastørrelse", - "duckdbDatabase": "Basert på DuckDB-lagring", + "tantivyDatabase": "Basert på søkemotoren Tantivy", "dayTrend": "30-dagers trend", "attachments": "Vedlegg", "topLists": "Topplister", @@ -195,7 +199,7 @@ "edit": "Rediger", "delete": "Slett", "detail": "Detaljer", - "syncFolders": "Synkroniser mapper", + "selectMailboxes": "Velg postbokser", "oauth2Tokens": "OAuth2-tokener", "id": "ID", "enabled": "Aktivert", @@ -223,16 +227,15 @@ "accountCreationFailed": "Kontoopprettelse mislyktes", "updateFailed": "Oppdatering mislyktes, prøv igjen senere", "creationFailed": "Opprettelse mislyktes, prøv igjen senere", - "selectSyncFolders": "Velg mapper for synkronisering", "allMailFolderSelected": "OBS: Mappen \"All e-post\" er valgt", "accountSyncFoldersUpdated": "Synkroniseringsmapper oppdatert", "accountSyncFoldersUpdateFailed": "Oppdatering av synkroniseringsmapper mislyktes", "serverConfiguration": "Serverkonfigurasjon (IMAP)", - "syncFoldersTitle": "Synkroniser mapper", + "selectedMailboxes": "Valgte postbokser", "allMailFolderSelectedDesc": "Å velge mapper med attributtet \"All e-post\" (All Mail) vil sannsynligvis føre til duplisering av meldinger som allerede er synkronisert fra mapper som Innboks og Sendt. Dette kan forbruke betydelig mer lagringsplass.", "allMailSkipped": "Valgte standardmapper. 'All e-post' ble hoppet over for å unngå duplikater.", "selectAtLeastOneFolder": "Vennligst velg minst én mappe", - "chooseFoldersToSync": "Velg mapper som skal synkroniseres for {{email}}. Nytilførte mapper vil begynne å lastes ned ved neste synkroniseringssyklus.", + "chooseMailboxesToDownload": "Velg postbokser som skal lastes ned for {{email}}. Nye postbokser lastes ned i neste syklus.", "accountDetails": "Kontodetaljer", "capabilities": "Funksjoner", "folderLimit": "Mappegrense", @@ -313,23 +316,38 @@ }, "viewDetails": "se detaljer", "runningState": { - "notStarted": "Ikke startet", - "inProgress": "Pågår", - "calculating": "Beregner...", - "initialSync": "Første synkronisering", - "completed": "Fullført", - "duration": "Varighet", - "startTime": "Starttid:", - "endTime": "Sluttid:", - "syncProgress": "Synkroniseringsfremdrift", - "incrementalSync": "Inkrementell synkronisering", - "syncInterval": "Synkroniseringsintervall:", - "everyMinutes": "Hvert {{minutes}} minutt", - "errorLogs": "Feillogger", - "noErrorLogs": "Ingen feillogger tilgjengelig.", - "noActiveState": "Ingen aktiv tilstand tilgjengelig", - "accountNotStarted": "Kontosynkronisering har kanskje ikke startet ennå", - "close": "Lukk" + "message": "Melding", + "account": { + "id": "Konto-ID" + }, + "tabs": { + "active_session": "Aktiv økt", + "history": "Historikk", + "global_errors": "Globale feil", + "folders": "Postbokser", + "errors": "Feil" + }, + "loading": { + "fetching_account_state": "Henter kontostatus..." + }, + "empty": { + "no_active_download": "Ingen aktiv nedlasting", + "no_errors_current": "Ingen feil i gjeldende økt", + "no_errors_session": "Ingen feil i denne økten", + "no_global_errors": "Ingen globale feil", + "no_history": "Ingen historikk" + }, + "session": { + "status": "Status", + "trigger": "Trigger", + "started_at": "Starttid" + }, + "common": { + "folders": "postbokser", + "close": "Lukk" + }, + "latest": "NYEST", + "folders": "postbokser" }, "steps": { "complete": "Fullfør", diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index 28f535f..7cd5be6 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -104,11 +104,15 @@ "totalEmails": "Wszystkie wiadomości", "syncedLocally": "Synchronizowane lokalnie", "totalEmailSize": "Wielkość wszystkich wiadomości", - "logicalVolume": "Wielkość wolumenu", + "logicalVolume": "Oryginalny rozmiar", "localDataFiles": "Lokalne pliki danych", "actualDiskUsage": "Zajętość na dysku", + "efficiency": "Wydajność", + "saved": "Zaoszczędzone", + "dataStorage": "Magazyn danych", + "indexSize": "Rozmiar indeksu", "metadataSize": "Rozmiar metadanych", - "duckdbDatabase": "Oparte na pamięci DuckDB", + "tantivyDatabase": "Oparte na silniku wyszukiwania Tantivy", "dayTrend": "Trend z 30 dni", "attachments": "Załączniki", "topLists": "Lista TOP", @@ -195,7 +199,7 @@ "edit": "Edytuj", "delete": "Usuń", "detail": "Szczegóły", - "syncFolders": "Synchronizuj foldery", + "selectMailboxes": "Wybierz skrzynki", "oauth2Tokens": "Tokeny OAuth2", "id": "ID", "enabled": "Aktywne", @@ -223,16 +227,15 @@ "accountCreationFailed": "Bład podczas tworzenia konta", "updateFailed": "Bład aktulizacji, spróbuj później", "creationFailed": "Bład tworzenia, spróbuj później", - "selectSyncFolders": "Wybierz foldery do synchronizacji", "allMailFolderSelected": "Uwaga: folder \"Wszystkie wiadomości\" izostał zaznaczony", "accountSyncFoldersUpdated": "Zaktualizowano foldery synchronizacji kont", "accountSyncFoldersUpdateFailed": "Nie udało się zaktualizować folderiów synchronizacji kont", "serverConfiguration": "Konfiguracja serwera (IMAP)", - "syncFoldersTitle": "Synchronizcja folderów", + "selectedMailboxes": "Wybrane skrzynki", "allMailFolderSelectedDesc": "Wybierając foldery z atrybutem \"Wszystkie wiadomości\" prawdopodobnie spowoduje to duplikowanie się wiadomości już zsynchronizowanych z folderów takich jak Skrzynka odbiorcza i Wysłane. Może to zająć znacznie więcej miejsca", "allMailSkipped": "Wybrane foldery standardowe. Pominięto 'Wszystkie wiadomości', aby uniknąć duplikatów.", "selectAtLeastOneFolder": "Wybierz co najmniej jeden folder", - "chooseFoldersToSync": "Wybierz foldery do synchronizacji {{email}} a nowo dodane foldery zaczną być pobierane podczas kolejnej synchronizacji.", + "chooseMailboxesToDownload": "Wybierz skrzynki do pobrania dla {{email}}. Nowe skrzynki zostaną pobrane w następnym cyklu.", "accountDetails": "Szczegóły konta", "capabilities": "Możliwości", "folderLimit": "Limit folderu", @@ -313,23 +316,38 @@ }, "viewDetails": "pokaż szczegóły", "runningState": { - "notStarted": "Nie uruchomione", - "inProgress": "W trakcie", - "calculating": "Obliczanie...", - "initialSync": "Synchronizacja początkowa", - "completed": "Wykonano", - "duration": "Czas trwania", - "startTime": "Czas rozpoczęcia:", - "endTime": "Czas zakończenia:", - "syncProgress": "Postęp synchronizacji", - "incrementalSync": "Synchronizacja przyrostowa", - "syncInterval": "Cykliczność synchronizacji:", - "everyMinutes": "Co {{minutes}} minut", - "errorLogs": "Dzienniki błędów", - "noErrorLogs": "Brak błędów w dziennikach logów.", - "noActiveState": "Brak wpisów", - "accountNotStarted": "Synchronizacja konta mogła się jeszcze nie rozpocząć", - "close": "Zamknij" + "message": "Wiadomość", + "account": { + "id": "ID konta" + }, + "tabs": { + "active_session": "Aktywna sesja", + "history": "Historia", + "global_errors": "Błędy globalne", + "folders": "Skrzynki", + "errors": "Błędy" + }, + "loading": { + "fetching_account_state": "Pobieranie stanu konta..." + }, + "empty": { + "no_active_download": "Brak aktywnego pobierania", + "no_errors_current": "Brak błędów w bieżącej sesji", + "no_errors_session": "Brak błędów w tej sesji", + "no_global_errors": "Brak błędów globalnych", + "no_history": "Brak historii" + }, + "session": { + "status": "Status", + "trigger": "Wyzwalacz", + "started_at": "Czas rozpoczęcia" + }, + "common": { + "folders": "skrzynki", + "close": "Zamknij" + }, + "latest": "NAJNOWSZE", + "folders": "skrzynki" }, "steps": { "complete": "Zakończono", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index e2bbbef..a6d9c13 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -104,11 +104,15 @@ "totalEmails": "Total de Emails", "syncedLocally": "Sincronizado Localmente", "totalEmailSize": "Tamanho Total do Email", - "logicalVolume": "Volume Lógico", + "logicalVolume": "Tamanho original", "localDataFiles": "Arquivos de Dados Locais", "actualDiskUsage": "Uso Real do Disco", + "efficiency": "Eficiência", + "saved": "Economizado", + "dataStorage": "Armazenamento", + "indexSize": "Tamanho índice", "metadataSize": "Tamanho dos metadatos", - "duckdbDatabase": "Baseado em armazenamento DuckDB", + "tantivyDatabase": "Baseado no motor de busca Tantivy", "dayTrend": "Tendência de 30 Dias", "attachments": "Anexos", "topLists": "Principais Listas", @@ -195,7 +199,7 @@ "edit": "Editar", "delete": "Excluir", "detail": "Detalhe", - "syncFolders": "Pastas de Sincronização", + "selectMailboxes": "Escolher caixas de correio", "oauth2Tokens": "Tokens OAuth2", "id": "ID", "enabled": "Ativo", @@ -223,16 +227,15 @@ "accountCreationFailed": "Falha ao Criar Conta", "updateFailed": "Falha na atualização, por favor, tente novamente mais tarde.", "creationFailed": "Falha na criação, por favor, tente novamente mais tarde.", - "selectSyncFolders": "Selecionar Pastas de Sincronização", "allMailFolderSelected": "Atenção: A pasta 'Todos os Emails' está selecionada", "accountSyncFoldersUpdated": "Pastas de Sincronização da Conta Atualizadas", "accountSyncFoldersUpdateFailed": "Falha ao Atualizar Pastas de Sincronização da Conta", "serverConfiguration": "Configuração do Servidor (IMAP)", - "syncFoldersTitle": "Pastas de Sincronização", + "selectedMailboxes": "Caixas de correio selecionadas", "allMailFolderSelectedDesc": "Selecionar uma pasta com o atributo 'Todos os Emails' pode duplicar mensagens já sincronizadas de pastas como Caixa de Entrada ou Itens Enviados. Isso pode consumir significativamente o seu armazenamento.", "allMailSkipped": "Pastas padrão selecionadas. 'Todos os Emails' foi ignorado para evitar duplicação.", "selectAtLeastOneFolder": "Por favor, selecione pelo menos uma pasta", - "chooseFoldersToSync": "Escolha as pastas para sincronizar para {{email}}. As pastas recém-adicionadas começarão a ser baixadas no próximo ciclo de sincronização.", + "chooseMailboxesToDownload": "Escolha as caixas de correio para baixar para {{email}}. Novas caixas serão baixadas no próximo ciclo.", "accountDetails": "Detalhes da Conta", "capabilities": "Capacidades", "folderLimit": "Limite de Pasta", @@ -313,23 +316,38 @@ }, "viewDetails": "Ver Detalhes", "runningState": { - "notStarted": "Não Iniciado", - "inProgress": "Em Progresso", - "calculating": "Calculando...", - "initialSync": "Sincronização Inicial", - "completed": "Concluído", - "duration": "Duração", - "startTime": "Hora de Início:", - "endTime": "Hora de Término:", - "syncProgress": "Progresso da Sincronização", - "incrementalSync": "Sincronização Incremental", - "syncInterval": "Intervalo de Sincronização:", - "everyMinutes": "A cada {{minutes}} minutos", - "errorLogs": "Logs de Erro", - "noErrorLogs": "Nenhum log de erro.", - "noActiveState": "Nenhum estado ativo", - "accountNotStarted": "A sincronização da conta pode não ter começado ainda", - "close": "Fechar" + "message": "Mensagem", + "account": { + "id": "ID da conta" + }, + "tabs": { + "active_session": "Sessão ativa", + "history": "Histórico", + "global_errors": "Erros globais", + "folders": "Caixas de correio", + "errors": "Erros" + }, + "loading": { + "fetching_account_state": "Obtendo estado da conta..." + }, + "empty": { + "no_active_download": "Nenhum download em andamento", + "no_errors_current": "Sem erros na sessão atual", + "no_errors_session": "Sem erros nesta sessão", + "no_global_errors": "Sem erros globais", + "no_history": "Sem histórico" + }, + "session": { + "status": "Status", + "trigger": "Gatilho", + "started_at": "Hora de início" + }, + "common": { + "folders": "caixas de correio", + "close": "Fechar" + }, + "latest": "RECENTE", + "folders": "caixas de correio" }, "steps": { "complete": "Concluído", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 75bd266..295469b 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -104,11 +104,15 @@ "totalEmails": "Всего писем", "syncedLocally": "Синхронизировано локально", "totalEmailSize": "Общий размер писем", - "logicalVolume": "Логический объем", + "logicalVolume": "Оригинальный размер", "localDataFiles": "Локальные файлы данных", "actualDiskUsage": "Фактическое использование диска", + "efficiency": "Эффективность", + "saved": "Сэкономлено", + "dataStorage": "Хранилище", + "indexSize": "Размер индекса", "metadataSize": "Размер метаданных", - "duckdbDatabase": "На базе хранилища DuckDB", + "tantivyDatabase": "На базе поискового движка Tantivy", "dayTrend": "Тренд за 30 дней", "attachments": "Вложения", "topLists": "Топ списки", @@ -195,7 +199,7 @@ "edit": "Ред.", "delete": "Удалить", "detail": "Подробно", - "syncFolders": "Папки синхр.", + "selectMailboxes": "Выбрать почтовые ящики", "oauth2Tokens": "Токены OAuth2", "id": "ID", "enabled": "Включено", @@ -223,16 +227,15 @@ "accountCreationFailed": "Ошибка создания аккаунта", "updateFailed": "Ошибка обновления, попробуйте позже", "creationFailed": "Ошибка создания, попробуйте позже", - "selectSyncFolders": "Выберите папки для синхронизации", "allMailFolderSelected": "Внимание: Выбрана папка \"Вся почта\"", "accountSyncFoldersUpdated": "Папки синхронизации обновлены", "accountSyncFoldersUpdateFailed": "Не удалось обновить папки синхронизации", "serverConfiguration": "Конфигурация сервера (IMAP)", - "syncFoldersTitle": "Папки синхронизации", + "selectedMailboxes": "Выбранные почтовые ящики", "allMailFolderSelectedDesc": "Выбор папок с атрибутом \"Вся почта\" скорее всего приведет к дублированию сообщений, уже синхронизированных из папок \"Входящие\" и \"Отправленные\". Это может занять значительно больше места.", "allMailSkipped": "Выбраны стандартные папки. Папка 'Вся почта' пропущена во избежание дубликатов.", "selectAtLeastOneFolder": "Пожалуйста, выберите хотя бы одну папку", - "chooseFoldersToSync": "Выберите папки для синхронизации для {{email}}. Новые добавленные папки начнут загружаться во время следующего цикла синхронизации.", + "chooseMailboxesToDownload": "Выберите почтовые ящики для загрузки для {{email}}. Новые ящики будут загружены в следующем цикле.", "accountDetails": "Детали аккаунта", "capabilities": "Возможности", "folderLimit": "Лимит папки", @@ -313,23 +316,38 @@ }, "viewDetails": "смотреть детали", "runningState": { - "notStarted": "Не начато", - "inProgress": "В процессе", - "calculating": "Вычисление...", - "initialSync": "Начальная синхронизация", - "completed": "Завершено", - "duration": "Длительность", - "startTime": "Время начала:", - "endTime": "Время окончания:", - "syncProgress": "Прогресс синхронизации", - "incrementalSync": "Инкрементальная синхронизация", - "syncInterval": "Интервал синхронизации:", - "everyMinutes": "Каждые {{minutes}} минут", - "errorLogs": "Журнал ошибок", - "noErrorLogs": "Журналы ошибок отсутствуют.", - "noActiveState": "Нет активного состояния", - "accountNotStarted": "Синхронизация аккаунта, возможно, еще не началась", - "close": "Закрыть" + "message": "Сообщение", + "account": { + "id": "ID аккаунта" + }, + "tabs": { + "active_session": "Активная сессия", + "history": "История", + "global_errors": "Глобальные ошибки", + "folders": "Почтовые ящики", + "errors": "Ошибки" + }, + "loading": { + "fetching_account_state": "Загрузка состояния аккаунта..." + }, + "empty": { + "no_active_download": "Нет активных загрузок", + "no_errors_current": "Нет ошибок в текущей сессии", + "no_errors_session": "Нет ошибок в этой сессии", + "no_global_errors": "Нет глобальных ошибок", + "no_history": "Нет истории" + }, + "session": { + "status": "Статус", + "trigger": "Триггер", + "started_at": "Время начала" + }, + "common": { + "folders": "почтовые ящики", + "close": "Закрыть" + }, + "latest": "ПОСЛЕДНЕЕ", + "folders": "почтовые ящики" }, "steps": { "complete": "Завершено", diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index c01ae40..4db171e 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -104,11 +104,15 @@ "totalEmails": "Totalt antal e-postmeddelanden", "syncedLocally": "Synkroniserat lokalt", "totalEmailSize": "Total e-poststorlek", - "logicalVolume": "Logisk volym", + "logicalVolume": "Ursprunglig storlek", "localDataFiles": "Lokala datafiler", "actualDiskUsage": "Faktisk diskanvändning", + "efficiency": "Lagringseffektivitet", + "saved": "Sparat", + "dataStorage": "Datalagring", + "indexSize": "Indexstorlek", "metadataSize": "Metadatastorlek", - "duckdbDatabase": "Baserat på DuckDB-lagring", + "tantivyDatabase": "Baserat på sökmotorn Tantivy", "dayTrend": "30-dagarstrend", "attachments": "Bilagor", "topLists": "Topplistor", @@ -195,7 +199,7 @@ "edit": "Redigera", "delete": "Ta bort", "detail": "Detaljer", - "syncFolders": "Synkronisera mappar", + "selectMailboxes": "Välj brevlådor", "oauth2Tokens": "OAuth2-tokens", "id": "ID", "enabled": "Aktiverad", @@ -223,16 +227,15 @@ "accountCreationFailed": "Kontoskapande misslyckades", "updateFailed": "Uppdatering misslyckades, försök igen senare", "creationFailed": "Skapande misslyckades, försök igen senare", - "selectSyncFolders": "Välj mappar att synkronisera", "allMailFolderSelected": "Observera: Mappen \"All e-post\" vald", "accountSyncFoldersUpdated": "Synkroniseringsmappar uppdaterade", "accountSyncFoldersUpdateFailed": "Uppdatering av synkroniseringsmappar misslyckades", "serverConfiguration": "Serverkonfiguration (IMAP)", - "syncFoldersTitle": "Synkronisera mappar", + "selectedMailboxes": "Valda brevlådor", "allMailFolderSelectedDesc": "Att välja mappar med attributet \"All e-post\" (All Mail) leder sannolikt till dubbletter av meddelanden som redan synkroniserats från mappar som Inkorg och Skickat. Detta kan ta upp betydligt mer lagringsutrymme.", "allMailSkipped": "Valde standardmappar. \"All e-post\" hoppades över för att undvika dubbletter.", "selectAtLeastOneFolder": "Vänligen välj minst en mapp", - "chooseFoldersToSync": "Välj mappar att synkronisera för {{email}}. Nyligen tillagda mappar börjar laddas ner vid nästa synkroniseringscykel.", + "chooseMailboxesToDownload": "Välj brevlådor att ladda ner för {{email}}. Nya brevlådor laddas ner i nästa cykel.", "accountDetails": "Kontodetaljer", "capabilities": "Funktioner", "folderLimit": "Mappgräns", @@ -313,23 +316,38 @@ }, "viewDetails": "visa detaljer", "runningState": { - "notStarted": "Ej startad", - "inProgress": "Pågår", - "calculating": "Beräknar...", - "initialSync": "Initial synkronisering", - "completed": "Slutförd", - "duration": "Varaktighet", - "startTime": "Starttid:", - "endTime": "Sluttid:", - "syncProgress": "Synkroniseringsförlopp", - "incrementalSync": "Inkrementell synkronisering", - "syncInterval": "Synkroniseringsintervall:", - "everyMinutes": "Varje {{minutes}} minut", - "errorLogs": "Felloggar", - "noErrorLogs": "Inga felloggar tillgängliga.", - "noActiveState": "Inget aktivt tillstånd tillgängligt", - "accountNotStarted": "Kontosynkronisering har kanske inte startat ännu", - "close": "Stäng" + "message": "Meddelande", + "account": { + "id": "Kontots ID" + }, + "tabs": { + "active_session": "Aktiv session", + "history": "Historik", + "global_errors": "Globala fel", + "folders": "Postlådor", + "errors": "Fel" + }, + "loading": { + "fetching_account_state": "Hämtar kontostatus..." + }, + "empty": { + "no_active_download": "Ingen aktiv nedladdning", + "no_errors_current": "Inga fel i aktuell session", + "no_errors_session": "Inga fel i denna session", + "no_global_errors": "Inga globala fel", + "no_history": "Ingen historik" + }, + "session": { + "status": "Status", + "trigger": "Trigger", + "started_at": "Starttid" + }, + "common": { + "folders": "postlådor", + "close": "Stäng" + }, + "latest": "SENASTE", + "folders": "postlådor" }, "steps": { "complete": "Slutför", diff --git a/web/src/locales/zh-tw.json b/web/src/locales/zh-tw.json index 76e597e..49db406 100644 --- a/web/src/locales/zh-tw.json +++ b/web/src/locales/zh-tw.json @@ -104,11 +104,15 @@ "totalEmails": "郵件總數", "syncedLocally": "本地同步數量", "totalEmailSize": "郵件總容量", - "logicalVolume": "邏輯容量", + "logicalVolume": "原始大小", "localDataFiles": "本地資料檔案", "actualDiskUsage": "實際磁碟用量", + "efficiency": "壓縮效率", + "saved": "節省空間", + "dataStorage": "資料儲存", + "indexSize": "索引體積", "metadataSize": "元數據大小", - "duckdbDatabase": "基於 DuckDB 存儲", + "tantivyDatabase": "基于 Tantivy 搜索引擎", "dayTrend": "30 天趨勢", "attachments": "附件", "topLists": "排行榜", @@ -195,7 +199,7 @@ "edit": "編輯", "delete": "刪除", "detail": "詳細資訊", - "syncFolders": "同步資料夾", + "selectMailboxes": "選擇郵件夾", "oauth2Tokens": "OAuth2 權杖", "id": "ID", "enabled": "啟用", @@ -223,16 +227,15 @@ "accountCreationFailed": "帳號建立失敗", "updateFailed": "更新失敗,請稍後再試。", "creationFailed": "建立失敗,請稍後再試。", - "selectSyncFolders": "選擇同步資料夾", "allMailFolderSelected": "注意:「所有郵件」資料夾已選擇", "accountSyncFoldersUpdated": "帳號同步資料夾已更新", "accountSyncFoldersUpdateFailed": "帳號同步資料夾更新失敗", "serverConfiguration": "伺服器設定 (IMAP)", - "syncFoldersTitle": "同步資料夾", + "selectedMailboxes": "已選擇的郵件夾", "allMailFolderSelectedDesc": "選擇具有「所有郵件」屬性的資料夾可能會導致已從收件匣或寄件備份等資料夾同步的郵件重複。這可能會大幅增加您的儲存用量。", "allMailSkipped": "標準資料夾已選擇,為避免重複已跳過「所有郵件」。", "selectAtLeastOneFolder": "請至少選擇一個資料夾", - "chooseFoldersToSync": "請選擇要為 {{email}} 同步的資料夾。新新增的資料夾將在下一個同步週期開始下載。", + "chooseMailboxesToDownload": "為 {{email}} 選擇要下載的郵件夾。新加入的郵件夾會在下個週期下載。", "accountDetails": "帳號詳細資訊", "capabilities": "功能", "folderLimit": "資料夾限制", @@ -313,23 +316,38 @@ }, "viewDetails": "檢視詳細資訊", "runningState": { - "notStarted": "未啟動", - "inProgress": "進行中", - "calculating": "正在計算...", - "initialSync": "初始同步", - "completed": "已完成", - "duration": "持續時間", - "startTime": "開始時間:", - "endTime": "結束時間:", - "syncProgress": "同步進度", - "incrementalSync": "增量同步", - "syncInterval": "同步間隔:", - "everyMinutes": "每 {{minutes}} 分鐘", - "errorLogs": "錯誤日誌", - "noErrorLogs": "無錯誤日誌。", - "noActiveState": "無活動狀態", - "accountNotStarted": "帳號同步可能尚未開始", - "close": "關閉" + "message": "訊息", + "account": { + "id": "帳戶 ID" + }, + "tabs": { + "active_session": "目前任務", + "history": "歷史記錄", + "global_errors": "全域錯誤", + "folders": "郵件夾", + "errors": "錯誤" + }, + "loading": { + "fetching_account_state": "正在取得帳戶狀態..." + }, + "empty": { + "no_active_download": "目前沒有下載任務", + "no_errors_current": "目前任務沒有錯誤", + "no_errors_session": "此任務沒有錯誤", + "no_global_errors": "沒有全域錯誤", + "no_history": "沒有歷史記錄" + }, + "session": { + "status": "狀態", + "trigger": "觸發方式", + "started_at": "開始時間" + }, + "common": { + "folders": "個郵件夾", + "close": "關閉" + }, + "latest": "最新", + "folders": "個郵件夾" }, "steps": { "complete": "完成", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index d6a185f..fb61062 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -104,11 +104,15 @@ "totalEmails": "邮件总数", "syncedLocally": "本地已同步", "totalEmailSize": "邮件总大小", - "logicalVolume": "逻辑卷", + "logicalVolume": "原始大小", "localDataFiles": "本地数据文件", "actualDiskUsage": "实际磁盘使用", + "efficiency": "压缩效率", + "saved": "节省空间", + "dataStorage": "数据存储", + "indexSize": "索引体积", "metadataSize": "元数据大小", - "duckdbDatabase": "基于 DuckDB 存储", + "tantivyDatabase": "基於 Tantivy 搜尋引擎", "dayTrend": "30天趋势", "attachments": "附件", "topLists": "排行榜", @@ -195,7 +199,7 @@ "edit": "编辑", "delete": "删除", "detail": "详情", - "syncFolders": "同步文件夹", + "selectMailboxes": "选择邮件夹", "oauth2Tokens": "OAuth2 令牌", "id": "ID", "enabled": "已启用", @@ -223,16 +227,15 @@ "accountCreationFailed": "账户创建失败", "updateFailed": "更新失败,请稍后重试", "creationFailed": "创建失败,请稍后重试", - "selectSyncFolders": "选择同步文件夹", "allMailFolderSelected": "提示:已选择\"所有邮件\"文件夹", "accountSyncFoldersUpdated": "账户同步文件夹已更新", "accountSyncFoldersUpdateFailed": "账户同步文件夹更新失败", "serverConfiguration": "服务器配置 (IMAP)", - "syncFoldersTitle": "同步文件夹", + "selectedMailboxes": "已选择的邮件夹", "allMailFolderSelectedDesc": "选择具有\"所有邮件\"属性的文件夹可能会导致重复已从收件箱和已发送等文件夹同步的消息。这可能会消耗更多的存储空间。", "allMailSkipped": "已选择标准文件夹。已跳过'所有邮件'以避免重复。", "selectAtLeastOneFolder": "请至少选择一个文件夹", - "chooseFoldersToSync": "选择要同步的文件夹({{email}}),新添加的文件夹将在下一个同步周期开始下载。", + "chooseMailboxesToDownload": "为 {{email}} 选择要下载的邮件夹。新添加的邮件夹将在下一个周期下载。", "accountDetails": "账户详情", "capabilities": "功能", "folderLimit": "文件夹限制", @@ -313,23 +316,38 @@ }, "viewDetails": "查看详情", "runningState": { - "notStarted": "未开始", - "inProgress": "进行中", - "calculating": "计算中...", - "initialSync": "初始同步", - "completed": "已完成", - "duration": "耗时", - "startTime": "开始时间:", - "endTime": "结束时间:", - "syncProgress": "同步进度", - "incrementalSync": "增量同步", - "syncInterval": "同步间隔:", - "everyMinutes": "每 {{minutes}} 分钟", - "errorLogs": "错误日志", - "noErrorLogs": "暂无错误日志", - "noActiveState": "暂无活动状态", - "accountNotStarted": "账户同步可能尚未开始", - "close": "关闭" + "message": "消息", + "account": { + "id": "账户 ID" + }, + "tabs": { + "active_session": "当前任务", + "history": "历史记录", + "global_errors": "全局错误", + "folders": "邮件夹", + "errors": "错误" + }, + "loading": { + "fetching_account_state": "正在获取账户状态..." + }, + "empty": { + "no_active_download": "当前没有下载任务", + "no_errors_current": "当前任务无错误", + "no_errors_session": "该任务无错误", + "no_global_errors": "暂无全局错误", + "no_history": "暂无历史记录" + }, + "session": { + "status": "状态", + "trigger": "触发方式", + "started_at": "开始时间" + }, + "common": { + "folders": "个文件夹", + "close": "关闭" + }, + "latest": "最新", + "folders": "个文件夹" }, "steps": { "complete": "完成", diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index b69cae9..ab44fca 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -17,8 +17,6 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", - /* Alias */ - "baseUrl": ".", "paths": { "@/*": [ "./src/*" diff --git a/web/tsconfig.json b/web/tsconfig.json index fec8c8e..ccf8818 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,13 +1,18 @@ { "files": [], "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } ], "compilerOptions": { - "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } } -} +} \ No newline at end of file