Compare commits

...
29 Commits
Author SHA1 Message Date
rustmailer f82b20e2fd chore: Set minimum username length to 3 #106 2026-01-14 01:11:01 +08:00
rustmailer 9841038acb chore: Adjust dark mode brightness and light mode saturation #109 2026-01-14 01:04:10 +08:00
rustmailer 97c3db3bd2 chore: default to binding 0.0.0.0 and support binding IPv6 addresses. 2026-01-14 00:27:31 +08:00
rustmailerandGitHub 82fb2a02bc Merge pull request #110 from op3/feat/support-listening-on-ipv6
Support listening on IPv6 addresses
2026-01-13 23:59:02 +08:00
rustmailerandGitHub c61977ce5c Merge pull request #107 from metlos/no-cap-on-sync-interval
Remove the maximum from the sync_interval_min.
2026-01-13 23:51:14 +08:00
rustmailer 106a08fb7e bump verison to 0.3.1 2026-01-13 23:34:46 +08:00
rustmailer 3419506c2e Update README.md 2026-01-13 23:34:17 +08:00
rustmailer 3b040d0cd6 feat: add support for Outlook PST file import #105 2026-01-13 23:32:18 +08:00
rustmailer 5d3c319a67 fix: skip invalid MBOX files during import 2026-01-13 23:31:45 +08:00
Oliver Papst bc3eba5bf7 feat: change default bind address to :: for dual‑stack support
The socket bound to :: accepts both IPv6 and IPv4 (mapped) connections,
so this change enables IPv6 connectivity in addition to the existing
IPv4 behaviour.
2026-01-10 22:36:31 +01:00
Oliver Papst 4dc99b4a84 feat: Add IPv6 support for bichon_bind_ip configuration
Also try to parse the bind_ip address as std::net::Ipv6Addr to accept
both IPv4 and IPv6 addresses. The TcpListener of poem utilizes
ToSocketAddrs trait, which also supports IPv6.
2026-01-10 22:25:31 +01:00
Lukas Krejci 8ce8b9692d Remove the maximum from the sync_interval_min. 2026-01-09 01:35:58 +01:00
rustmailer 3fb761064d Update README.md 2026-01-08 10:52:30 +08:00
rustmailer 54a0a71c44 fix: Missing permission 'user:manage' #102 2026-01-07 23:01:25 +08:00
rustmailer b490923e17 refactor(search): search filtering and sorting 2026-01-07 16:40:45 +08:00
rustmailerandGitHub 4ee44daf0d Merge pull request #103 from ktdd/presets-and-sort
Updated presets and added a 'sort by' feature.
2026-01-07 15:03:17 +08:00
ktdd 7edd7c2e35 Updated presets and added a 'sort by' feature. 2026-01-06 12:33:09 +02:00
rustmailer 0c46432150 fix: Inline attachments are not counted as attachments and are not shown when searching for emails with attachments. 2026-01-06 16:28:55 +08:00
rustmailer d334a23ca7 chore(ui): add attachment file type icon 2026-01-06 16:27:05 +08:00
rustmailer 1768c1a590 fix: Large empty space at the bottom of the screen #98 2026-01-06 14:36:01 +08:00
rustmailer 147f5b4f55 Update README.md 2026-01-05 22:34:24 +08:00
rustmailer 48312dc83e Update README.md 2026-01-05 22:21:53 +08:00
rustmailer 55e97510c4 fix: Folder limit cannot be empty #97 2026-01-05 21:32:00 +08:00
rustmailer 0bf2003670 chore(release): package bichonctl together with bichon binaries 2026-01-05 18:38:34 +08:00
rustmailer e56fe5ebea feat(mailbox): support mailbox cleanup #96 2026-01-05 18:27:40 +08:00
rustmailer c69ada32ef feat(ui): add clickable logo to redirect to homepage #95 2026-01-05 14:31:47 +08:00
rustmailer 3f4b37be17 feat(cli): add interactive email import tool for EML, MBOX, and Thunderbird
- Implement `bichonctl` interactive CLI using `dialoguer`.
- Support recursive EML directory scanning with folder structure preservation.
- Support single MBOX file streaming import.
- Support Thunderbird profile import with automatic `.sbd` hierarchy detection.
- Add batch processing (Base64 encoding & batch API requests) for improved performance.
2026-01-05 14:31:04 +08:00
rustmailer 09375ee11c fix: #94 2026-01-01 20:28:23 +08:00
rustmailer b2e43b0907 fix: skip default admin role validation when global_roles is None #93 2026-01-01 15:24:51 +08:00
92 changed files with 3186 additions and 391 deletions
+9 -3
View File
@@ -6,6 +6,7 @@ on:
- '[0-9]+.[0-9]+.[0-9]+' - '[0-9]+.[0-9]+.[0-9]+'
env: env:
BINARY_NAME: bichon BINARY_NAME: bichon
BINARY_CTL: bichonctl
permissions: permissions:
contents: write contents: write
@@ -91,6 +92,7 @@ jobs:
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu' if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
run: | run: |
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
strip target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}
- name: Pack artifact (Linux/macOS) - name: Pack artifact (Linux/macOS)
if: matrix.os != 'windows-latest' if: matrix.os != 'windows-latest'
@@ -99,7 +101,8 @@ jobs:
mkdir -p release mkdir -p release
BINARY="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}" BINARY="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
cp README.md LICENSE release/ cp README.md LICENSE release/
cp "$BINARY" release/ cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_CTL }} release/
tar -czvf "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" -C release . tar -czvf "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" -C release .
mv "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" release/ mv "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" release/
@@ -115,10 +118,13 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
mkdir -p release mkdir -p release
$BINARY = "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe"
Copy-Item -Path $BINARY -Destination release/ Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" release/
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}.exe" release/
Copy-Item -Path README.md -Destination release/ Copy-Item -Path README.md -Destination release/
Copy-Item -Path LICENSE -Destination release/ Copy-Item -Path LICENSE -Destination release/
Compress-Archive -Path release\* -DestinationPath "release/${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.zip" -Force Compress-Archive -Path release\* -DestinationPath "release/${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.zip" -Force
- name: Upload build artifact - name: Upload build artifact
Generated
+531 -26
View File
@@ -25,7 +25,18 @@ checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561"
dependencies = [ dependencies = [
"aes-soft", "aes-soft",
"aesni", "aesni",
"cipher", "cipher 0.2.5",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cpufeatures",
] ]
[[package]] [[package]]
@@ -35,8 +46,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5278b5fabbb9bd46e24aa69b2fdea62c99088e0a950a9be40e3e0101298f88da" checksum = "5278b5fabbb9bd46e24aa69b2fdea62c99088e0a950a9be40e3e0101298f88da"
dependencies = [ dependencies = [
"aead", "aead",
"aes", "aes 0.6.0",
"cipher", "cipher 0.2.5",
"ctr", "ctr",
"ghash", "ghash",
"subtle", "subtle",
@@ -48,7 +59,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072" checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072"
dependencies = [ dependencies = [
"cipher", "cipher 0.2.5",
"opaque-debug", "opaque-debug",
] ]
@@ -58,7 +69,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce" checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce"
dependencies = [ dependencies = [
"cipher", "cipher 0.2.5",
"opaque-debug", "opaque-debug",
] ]
@@ -170,6 +181,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 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]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.7.1" version = "1.7.1"
@@ -424,7 +444,7 @@ dependencies = [
[[package]] [[package]]
name = "bichon" name = "bichon"
version = "0.2.2" version = "0.3.1"
dependencies = [ dependencies = [
"ahash", "ahash",
"async-imap", "async-imap",
@@ -435,7 +455,11 @@ dependencies = [
"cacache", "cacache",
"chrono", "chrono",
"clap", "clap",
"codepage-strings",
"compressed-rtf",
"console",
"dashmap", "dashmap",
"dialoguer",
"email_address", "email_address",
"encoding_rs", "encoding_rs",
"futures", "futures",
@@ -449,6 +473,8 @@ dependencies = [
"itoa", "itoa",
"lru 0.16.2", "lru 0.16.2",
"mail-parser", "mail-parser",
"mail-send",
"memmap2 0.9.9",
"mimalloc", "mimalloc",
"mime_guess", "mime_guess",
"murmur3", "murmur3",
@@ -457,6 +483,7 @@ dependencies = [
"num_cpus", "num_cpus",
"oauth2", "oauth2",
"openssl-sys", "openssl-sys",
"outlook-pst",
"poem", "poem",
"poem-derive", "poem-derive",
"poem-openapi", "poem-openapi",
@@ -480,6 +507,7 @@ dependencies = [
"tokio-io-timeout", "tokio-io-timeout",
"tokio-rustls", "tokio-rustls",
"tokio-socks", "tokio-socks",
"toml",
"tracing", "tracing",
"tracing-appender", "tracing-appender",
"tracing-subscriber", "tracing-subscriber",
@@ -620,6 +648,15 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]] [[package]]
name = "cacache" name = "cacache"
version = "13.1.0" version = "13.1.0"
@@ -732,10 +769,20 @@ dependencies = [
] ]
[[package]] [[package]]
name = "clap" name = "cipher"
version = "4.5.53" version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@@ -743,9 +790,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.53" version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@@ -780,12 +827,42 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "codepage"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
dependencies = [
"encoding_rs",
]
[[package]]
name = "codepage-strings"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96e0dcc0fce1af8fe139537bcd0c2522296a701bf11eb6b143ca88ff8c742523"
dependencies = [
"codepage",
"encoding_rs",
"oem_cp",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "compressed-rtf"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50bd502b5779b9f999a2d69115f7e2fb1662883aa5a0e50e33cff684dae101d9"
dependencies = [
"byteorder",
"thiserror 2.0.17",
]
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.33" version = "0.4.33"
@@ -815,12 +892,31 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "console"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width 0.2.2",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "const_fn" name = "const_fn"
version = "0.4.11" version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f8a2ca5ac02d09563609681103aada9e1777d54fc57a5acd7a41404f9c93b6e" checksum = "2f8a2ca5ac02d09563609681103aada9e1777d54fc57a5acd7a41404f9c93b6e"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.14.4" version = "0.14.4"
@@ -830,7 +926,7 @@ dependencies = [
"aes-gcm", "aes-gcm",
"base64 0.13.1", "base64 0.13.1",
"hkdf", "hkdf",
"hmac", "hmac 0.10.1",
"percent-encoding", "percent-encoding",
"rand 0.8.5", "rand 0.8.5",
"sha2 0.9.9", "sha2 0.9.9",
@@ -859,6 +955,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcb25d077389e53838a8158c8e99174c5a9d902dee4904320db714f3c653ffba" 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]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -868,6 +979,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.15" version = "0.5.15"
@@ -934,7 +1051,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb4a30d54f7443bf3d6191dcd486aca19e67cb3c49fa7a06a319966346707e7f" checksum = "fb4a30d54f7443bf3d6191dcd486aca19e67cb3c49fa7a06a319966346707e7f"
dependencies = [ dependencies = [
"cipher", "cipher 0.2.5",
] ]
[[package]] [[package]]
@@ -1058,6 +1175,12 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "deflate64"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.5" version = "0.5.5"
@@ -1068,6 +1191,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.0.1" version = "2.0.1"
@@ -1089,6 +1223,18 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "dialoguer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
dependencies = [
"console",
"shell-words",
"tempfile",
"zeroize",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.9.0" version = "0.9.0"
@@ -1106,6 +1252,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@@ -1152,6 +1299,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -1173,6 +1326,18 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "enum-as-inner"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1259,6 +1424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"libz-rs-sys",
"miniz_oxide", "miniz_oxide",
] ]
@@ -1664,6 +1830,60 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hickory-proto"
version = "0.26.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a62d7684f766b0f96344be88c023f9b6650039aea09d526b4974cce302eb61b1"
dependencies = [
"async-trait",
"bitflags",
"bytes 1.11.0",
"cfg-if",
"data-encoding",
"enum-as-inner 0.6.1",
"futures-channel",
"futures-io",
"futures-util",
"idna 1.1.0",
"ipnet",
"once_cell",
"rand 0.9.2",
"ring",
"rustls",
"rustls-pki-types",
"thiserror 2.0.17",
"time 0.3.44",
"tinyvec",
"tokio",
"tokio-rustls",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
version = "0.26.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbab5e26a7f82341145ba1fbd1f1858d0490624fcc46270db2d3c4a101f763f4"
dependencies = [
"cfg-if",
"futures-util",
"hickory-proto",
"ipconfig",
"moka",
"once_cell",
"parking_lot",
"rand 0.9.2",
"resolv-conf",
"rustls",
"smallvec",
"thiserror 2.0.17",
"tokio",
"tokio-rustls",
"tracing",
]
[[package]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.10.0" version = "0.10.0"
@@ -1671,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51ab2f639c231793c5f6114bdb9bbe50a7dbbfcd7c7c6bd8475dec2d991e964f" checksum = "51ab2f639c231793c5f6114bdb9bbe50a7dbbfcd7c7c6bd8475dec2d991e964f"
dependencies = [ dependencies = [
"digest 0.9.0", "digest 0.9.0",
"hmac", "hmac 0.10.1",
] ]
[[package]] [[package]]
@@ -1684,6 +1904,15 @@ dependencies = [
"digest 0.9.0", "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]] [[package]]
name = "html2text" name = "html2text"
version = "0.16.5" version = "0.16.5"
@@ -2045,6 +2274,15 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" 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]] [[package]]
name = "instant" name = "instant"
version = "0.1.13" version = "0.1.13"
@@ -2176,6 +2414,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
[[package]]
name = "libbz2-rs-sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.177" version = "0.2.177"
@@ -2208,6 +2452,15 @@ dependencies = [
"libc", "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]] [[package]]
name = "libz-sys" name = "libz-sys"
version = "1.1.23" version = "1.1.23"
@@ -2295,12 +2548,52 @@ version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
[[package]]
name = "lzma-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a"
dependencies = [
"crc",
"sha2 0.10.9",
]
[[package]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mail-auth"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b7da45f78cc525d3750b623c967ae21c0cd28b2e6a9a2ee4b536a7cce3b21ce"
dependencies = [
"ahash",
"flate2",
"hashify",
"hickory-resolver",
"mail-builder",
"mail-parser",
"quick-xml 0.38.4",
"quick_cache",
"ring",
"rustls-pki-types",
"serde",
"serde_json",
"zip",
]
[[package]]
name = "mail-builder"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b"
dependencies = [
"gethostname",
]
[[package]] [[package]]
name = "mail-parser" name = "mail-parser"
version = "0.11.1" version = "0.11.1"
@@ -2312,6 +2605,26 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "mail-send"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "114a4e27f3cfaf8918783e8fa4149b820c813b1bedc7755e20e12eff4518331e"
dependencies = [
"base64 0.22.1",
"gethostname",
"mail-auth",
"mail-builder",
"md5",
"rand 0.9.2",
"rustls",
"rustls-pki-types",
"smtp-proto",
"tokio",
"tokio-rustls",
"webpki-roots",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.36.1" version = "0.36.1"
@@ -2338,6 +2651,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "md5"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
[[package]] [[package]]
name = "measure_time" name = "measure_time"
version = "0.9.0" version = "0.9.0"
@@ -2446,6 +2765,23 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moka"
version = "0.12.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a"
dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"parking_lot",
"portable-atomic",
"smallvec",
"tagptr",
"uuid",
]
[[package]] [[package]]
name = "multer" name = "multer"
version = "3.1.0" version = "3.1.0"
@@ -2655,11 +2991,25 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "oem_cp"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a95602f5a6eec5b15394516448c0b6fc2f2ef4ca2a9ab2951a91a18cec516bf0"
dependencies = [
"ahash",
"lazy_static",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]] [[package]]
name = "once_cell_polyfill" name = "once_cell_polyfill"
@@ -2707,6 +3057,16 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "outlook-pst"
version = "1.1.0"
source = "git+https://github.com/rustmailer/outlook-pst-rs.git?branch=main#c12f3595ee5a6f8407d19497133725a3741570aa"
dependencies = [
"byteorder",
"thiserror 2.0.17",
"tracing",
]
[[package]] [[package]]
name = "ownedbytes" name = "ownedbytes"
version = "0.9.0" version = "0.9.0"
@@ -2745,6 +3105,16 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.10.7",
"hmac 0.12.1",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -2880,7 +3250,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"poem-derive", "poem-derive",
"quick-xml", "quick-xml 0.36.2",
"regex", "regex",
"rfc7239", "rfc7239",
"rust-embed", "rust-embed",
@@ -2930,7 +3300,7 @@ dependencies = [
"num-traits", "num-traits",
"poem", "poem",
"poem-openapi-derive", "poem-openapi-derive",
"quick-xml", "quick-xml 0.36.2",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
@@ -3004,6 +3374,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -3089,6 +3465,27 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick_cache"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3"
dependencies = [
"ahash",
"equivalent",
"hashbrown 0.16.1",
"parking_lot",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -3700,6 +4097,15 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "serde_spanned"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -3795,6 +4201,12 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -3869,6 +4281,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smtp-proto"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55cc1c74d3b758d7dd1fa4dc4cf694cad2732cac14f304228477c2b0ce6233a"
[[package]] [[package]]
name = "snafu" name = "snafu"
version = "0.8.9" version = "0.8.9"
@@ -4143,6 +4561,12 @@ dependencies = [
"windows 0.61.3", "windows 0.61.3",
] ]
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]] [[package]]
name = "tantivy" name = "tantivy"
version = "0.25.0" version = "0.25.0"
@@ -4474,9 +4898,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.48.0" version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [ dependencies = [
"bytes 1.11.0", "bytes 1.11.0",
"libc", "libc",
@@ -4557,10 +4981,25 @@ dependencies = [
] ]
[[package]] [[package]]
name = "toml_datetime" name = "toml"
version = "0.7.3" version = "0.9.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -4579,13 +5018,19 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.0.4" version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
dependencies = [ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.2" version = "0.5.2"
@@ -4737,7 +5182,7 @@ dependencies = [
"async-trait", "async-trait",
"cfg-if", "cfg-if",
"data-encoding", "data-encoding",
"enum-as-inner", "enum-as-inner 0.5.1",
"futures-channel", "futures-channel",
"futures-io", "futures-io",
"futures-util", "futures-util",
@@ -4863,14 +5308,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.7" version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna 1.1.0", "idna 1.1.0",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_derive",
] ]
[[package]] [[package]]
@@ -5693,6 +6139,20 @@ name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
@@ -5727,12 +6187,57 @@ dependencies = [
"syn 2.0.111", "syn 2.0.111",
] ]
[[package]]
name = "zip"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
dependencies = [
"aes 0.8.4",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.3.4",
"hmac 0.12.1",
"indexmap",
"lzma-rust2",
"memchr",
"pbkdf2",
"ppmd-rust",
"sha1 0.10.6",
"time 0.3.44",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zlib-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.5" version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3280a1b827474fcd5dbef4b35a674deb52ba5c312363aef9135317df179d81b" 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]] [[package]]
name = "zstd" name = "zstd"
version = "0.13.3" version = "0.13.3"
+15 -4
View File
@@ -1,12 +1,15 @@
[package] [package]
name = "bichon" name = "bichon"
version = "0.2.2" version = "0.3.1"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
name = "bichon" name = "bichon"
path = "src/main.rs" path = "src/main.rs"
[[bin]]
name = "bichonctl"
path = "src/bin/bichonctl.rs"
[features] [features]
default = [] default = []
@@ -20,7 +23,7 @@ codegen-units = 1
[dependencies] [dependencies]
chrono = "0.4.42" chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive", "env"] } clap = { version = "4.5.54", features = ["derive", "env"] }
mimalloc = "0.1.48" mimalloc = "0.1.48"
native_db = "0.8.2" native_db = "0.8.2"
itertools = "0.14.0" itertools = "0.14.0"
@@ -38,7 +41,7 @@ poem-openapi = { version = "5.1.16", features = [
ring = { version = "0.17.14", features = ["std"] } ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148" serde_json = "1.0.148"
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.49.0", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-appender = "0.2.3" tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
@@ -68,7 +71,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
timeago = "0.5.0" timeago = "0.5.0"
ahash = "0.8.12" ahash = "0.8.12"
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] } oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
url = { version = "2.5.7", features = ["serde"] } url = { version = "2.5.8", features = ["serde"] }
sysinfo = "0.37.2" sysinfo = "0.37.2"
num_cpus = "1.17.0" num_cpus = "1.17.0"
cacache = { version = "13.1.0", default-features = false, features = [ cacache = { version = "13.1.0", default-features = false, features = [
@@ -108,6 +111,14 @@ tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
itoa = "1.0.17" itoa = "1.0.17"
html2text = "0.16.5" html2text = "0.16.5"
bytes = "1.11.0" bytes = "1.11.0"
dialoguer = "0.12.0"
console = "0.16.2"
toml = "0.9.8"
memmap2 = "0.9.9"
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
compressed-rtf = "1.0.0"
codepage-strings = "1.0.2"
mail-send = "0.5.2"
[dev-dependencies] [dev-dependencies]
#bincode = "1.3.3" #bincode = "1.3.3"
#secret-lib = "1.0.0" #secret-lib = "1.0.0"
+28 -9
View File
@@ -65,7 +65,7 @@ Built in Rust, it requires no external dependencies and provides fast, efficient
* **Internationalized WebUI** — Frontend available in 18 languages * **Internationalized WebUI** — Frontend available in 18 languages
* **OpenAPI Access** — OpenAPI docs with access-token authentication * **OpenAPI Access** — OpenAPI docs with access-token authentication
* **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions * **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions
* **Email Import (EML & MBOX)** — Import existing mail archives via the bichonctl CLI
## 🐾 Why Create Bichon? ## 🐾 Why Create Bichon?
@@ -304,6 +304,20 @@ After logging in, the admin user can manage their profile directly in the WebUI:
⚠️ **Security Notice:** ⚠️ **Security Notice:**
For security reasons, you should **change the default admin password immediately after the first login**. For security reasons, you should **change the default admin password immediately after the first login**.
## 📦 Import Existing Mail Archives
If you already have existing emails stored as **EML** or **MBOX** files, you can import them into Bichon using the `bichonctl` CLI.
This allows you to:
- Index historical emails
- Perform full-text search immediately
- Manage imported data just like synced IMAP emails
📖 **Full documentation:**
👉 https://github.com/rustmailer/bichon/wiki/Using-Bichonctl-For-Email-Import
## 📖 Documentation ## 📖 Documentation
> Under construction. Documentation will be available soon. > Under construction. Documentation will be available soon.
@@ -335,13 +349,14 @@ This data is provided solely as a **reference** for real-world usage. We encoura
## Roadmap ## Roadmap
- ✓ Multi-user support with account/password login * [x] Multi-user support with account/password login
- System-level roles (admin / user) * [x] System-level roles (admin / user)
- Per-mail-account permissions * [x] Per-mail-account permissions
* [ ] `bichon-cli` command-line tool * [x] `bichonctl` command-line tool
* Import emails from `eml`, `mbox`, `msg`, `pst` * [x] Import emails from `eml`, `mbox`, `pst` (Single file)
* [ ] Import emails from `msg`
* [ ] Manual sync controls * [ ] Manual sync controls
@@ -455,9 +470,13 @@ This project is licensed under [AGPLv3](LICENSE).
## 💖 Support & Promotion ## 💖 Support & Promotion
If this project has been helpful to you and youd like to support its development, you can consider making a small donation or helping spread the word. Bichon is an open-source email platform focused on privacy, local ownership, and long-term stability.
Financial support is optional but deeply appreciated — it helps me dedicate more time and resources to building new features and improving the overall experience.
You can also support the project by sharing it with others, writing about your experience, or recommending it within relevant communities. Every bit of visibility helps more people benefit from the tool! The project is freely available and fully functional for everyone.
Some members of the community choose to support the project financially. This support helps sustain ongoing development and long-term maintenance, while keeping the project independent and user-driven.
Support is always optional. You can also contribute by sharing feedback, reporting issues, or recommending Bichon to others.
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Support%20the%20Project-FFDD00?logo=buy-me-a-coffee)](https://buymeacoffee.com/rustmailer) [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Support%20the%20Project-FFDD00?logo=buy-me-a-coffee)](https://buymeacoffee.com/rustmailer)
+2
View File
@@ -0,0 +1,2 @@
base_url = "http://localhost:15630"
api_token = "UvkGJO0Mn1tO6igGZwQnTtI2"
+1 -1
View File
@@ -28,7 +28,7 @@ BICHON_ROOT_DIR=/data/bichon-data
# Enable API access token validation # Enable API access token validation
BICHON_ENABLE_ACCESS_TOKEN=false BICHON_ENABLE_ACCESS_TOKEN=false
# IP address to bind the HTTP and gRPC servers to (default: 0.0.0.0) # IP address to bind the HTTP servers to (default: 0.0.0.0)
BICHON_BIND_IP= BICHON_BIND_IP=
# Comma-separated list of allowed CORS origins (e.g. https://app.example.com) # Comma-separated list of allowed CORS origins (e.g. https://app.example.com)
+95
View File
@@ -0,0 +1,95 @@
use bichon::modules::cli::{
BichonCli, BichonCtlConfig, auth::verify_user_and_get_account, eml::handle_eml_directory_import, mbox::handle_mbox_single_file_import, pst::handle_pst_import, thunderbird::handle_thunderbird_import
};
use clap::Parser;
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use std::fs;
#[tokio::main]
async fn main() {
let cli = BichonCli::parse();
let theme = ColorfulTheme::default();
let config_path = &cli.config;
let mut current_config: Option<BichonCtlConfig> = None;
if config_path.exists() {
if let Ok(content) = fs::read_to_string(config_path) {
if let Ok(config) = toml::from_str::<BichonCtlConfig>(&content) {
println!("{}", style("✔ Existing configuration found:").green());
println!(" Base URL: {}", style(&config.base_url).yellow());
println!(" API Token: {}", style(&config.api_token).yellow());
// Confirm with user
if Confirm::with_theme(&theme)
.with_prompt("Do you want to use this configuration?")
.default(true)
.interact()
.unwrap()
{
current_config = Some(config);
}
}
}
}
let final_config = match current_config {
Some(conf) => conf,
None => {
println!("\n{}", style("Please enter Bichon service details:").bold());
let url: String = Input::with_theme(&theme)
.with_prompt("Bichon Base URL")
.default("http://localhost:15630".into())
.interact_text()
.unwrap();
let token: String = Input::with_theme(&theme)
.with_prompt("API Token")
.interact_text()
.unwrap();
let conf = BichonCtlConfig {
base_url: url,
api_token: token,
};
// 3. Offer to save the new configuration
if Confirm::with_theme(&theme)
.with_prompt("Save this configuration for future use?")
.default(true)
.interact()
.unwrap()
{
let toml_str = toml::to_string(&conf).unwrap();
fs::write(config_path, toml_str).expect("Failed to save config file");
println!("{}", style("Configuration saved successfully!").green());
}
conf
}
};
let target_account_id = verify_user_and_get_account(&final_config, &theme).await;
let import_modes = &[
"EML: Scan directory recursively (Maintains folder structure)",
"MBOX: Single archive file (Stream from one file)",
"Thunderbird: Import from local profile directory",
"PST: Outlook Personal Storage (Single .pst file)",
];
let mode_idx = Select::with_theme(&theme)
.with_prompt("Select import method")
.items(import_modes)
.default(0)
.interact()
.unwrap();
match mode_idx {
0 => handle_eml_directory_import(&final_config, target_account_id, &theme).await,
1 => handle_mbox_single_file_import(&final_config, target_account_id, &theme).await,
2 => handle_thunderbird_import(&final_config, target_account_id, &theme).await,
3 => handle_pst_import(&final_config, target_account_id, &theme).await,
_ => unreachable!(),
}
}
+1
View File
@@ -0,0 +1 @@
pub mod modules;
+12 -11
View File
@@ -16,23 +16,24 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use mimalloc::MiMalloc; use bichon::{
use modules::{ bichon_version,
common::rustls::RustMailerTls, modules::{
context::{executors::EmailClientExecutors, Initialize}, common::rustls::RustMailerTls,
error::BichonResult, context::{executors::EmailClientExecutors, Initialize},
logger, error::BichonResult,
rest::start_http_server, logger,
tasks::PeriodicTasks, rest::start_http_server,
tasks::PeriodicTasks,
},
}; };
use mimalloc::MiMalloc;
use tracing::info; use tracing::info;
use crate::modules::{ use bichon::modules::{
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager, common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
}; };
mod modules;
#[global_allocator] #[global_allocator]
static GLOBAL: MiMalloc = MiMalloc; static GLOBAL: MiMalloc = MiMalloc;
+10 -2
View File
@@ -363,11 +363,13 @@ impl AccountV3 {
list_all_impl(DB_MANAGER.meta_db()).await list_all_impl(DB_MANAGER.meta_db()).await
} }
pub async fn minimal_list() -> BichonResult<Vec<MinimalAccount>> { pub async fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
let result = list_all_impl(DB_MANAGER.meta_db()) let result = list_all_impl(DB_MANAGER.meta_db())
.await? .await?
.into_iter() .into_iter()
//.filter(|a: &AccountModel| a.enabled) .filter(|account: &AccountModel| {
!only_nosync || matches!(account.account_type, AccountType::NoSync)
})
.map(|account: AccountModel| MinimalAccount { .map(|account: AccountModel| MinimalAccount {
id: account.id, id: account.id,
email: account.email, email: account.email,
@@ -419,6 +421,12 @@ impl AccountV3 {
new.folder_limit = Some(folder_limit); new.folder_limit = Some(folder_limit);
} }
if let Some(clear_folder_limit) = request.clear_folder_limit {
if clear_folder_limit {
new.folder_limit = None;
}
}
if let Some(name) = &request.name { if let Some(name) = &request.name {
if name.trim().is_empty() { if name.trim().is_empty() {
new.name = None; new.name = None;
+10 -2
View File
@@ -37,7 +37,7 @@ pub struct AccountCreateRequest {
pub account_type: AccountType, pub account_type: AccountType,
#[oai(validator(minimum(value = "100")))] #[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>, pub folder_limit: Option<u32>,
#[oai(validator(minimum(value = "10"), maximum(value = "480")))] #[oai(validator(minimum(value = "10")))]
pub sync_interval_min: Option<i64>, pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))] #[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>, pub sync_batch_size: Option<u32>,
@@ -127,6 +127,7 @@ pub struct AccountUpdateRequest {
/// otherwise sync up to `n` most recent emails (min 10). /// otherwise sync up to `n` most recent emails (min 10).
#[oai(validator(minimum(value = "100")))] #[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>, pub folder_limit: Option<u32>,
pub clear_folder_limit: Option<bool>,
/// Configuration for selective folder (mailbox/label) synchronization /// Configuration for selective folder (mailbox/label) synchronization
/// ///
/// - For IMAP/SMTP accounts: /// - For IMAP/SMTP accounts:
@@ -142,7 +143,7 @@ pub struct AccountUpdateRequest {
/// Modified folders will be automatically synced on the next update. /// Modified folders will be automatically synced on the next update.
pub sync_folders: Option<Vec<String>>, pub sync_folders: Option<Vec<String>>,
/// Incremental sync interval (seconds) /// Incremental sync interval (seconds)
#[oai(validator(minimum(value = "10"), maximum(value = "480")))] #[oai(validator(minimum(value = "10")))]
pub sync_interval_min: Option<i64>, pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))] #[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>, pub sync_batch_size: Option<u32>,
@@ -166,6 +167,13 @@ impl AccountUpdateRequest {
)); ));
} }
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
return Err(raise_error!(
"clear_folder_limit cannot be combined with folder_limit".into(),
ErrorCode::InvalidParameter
));
}
if self.clear_date_range == Some(true) if self.clear_date_range == Some(true)
&& (self.date_since.is_some() || self.date_before.is_some()) && (self.date_since.is_some() || self.date_before.is_some())
{ {
+20 -21
View File
@@ -16,13 +16,12 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{ use crate::{
decode_mailbox_name, encode_mailbox_name, decode_mailbox_name, encode_mailbox_name,
modules::{ modules::{
database::{ database::{
batch_delete_impl, batch_insert_impl, batch_upsert_impl, filter_by_secondary_key_impl, async_find_impl, batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl,
manager::DB_MANAGER, filter_by_secondary_key_impl, manager::DB_MANAGER,
}, },
error::{code::ErrorCode, BichonResult}, error::{code::ErrorCode, BichonResult},
}, },
@@ -90,25 +89,25 @@ impl MailBox {
// Ok(()) // Ok(())
// } // }
// pub async fn get(id: u64) -> RustMailerResult<MailBox> { pub async fn get(id: u64) -> BichonResult<MailBox> {
// let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?; let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
// Ok(result.ok_or_else(|| { Ok(result.ok_or_else(|| {
// raise_error!( raise_error!(
// format!("mailbox {} not found", id), format!("mailbox {} not found", id),
// ErrorCode::InternalError ErrorCode::InternalError
// ) )
// })?) })?)
// } }
// pub async fn delete(id: u64) -> BichonResult<()> { pub async fn delete(id: u64) -> BichonResult<()> {
// delete_impl(DB_MANAGER.envelope_db(), move |rw| { delete_impl(DB_MANAGER.envelope_db(), move |rw| {
// rw.get() rw.get()
// .primary::<MailBox>(id) .primary::<MailBox>(id)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// .ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError)) .ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
// }) })
// .await .await
// } }
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> { pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id) filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id)
+169
View File
@@ -0,0 +1,169 @@
use std::process;
use console::style;
use dialoguer::{theme::ColorfulTheme, Select};
use reqwest::Client;
use crate::modules::{
account::payload::MinimalAccount,
cli::BichonCtlConfig,
users::{permissions::Permission, view::UserView},
};
pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &ColorfulTheme) -> u64 {
let client = Client::new();
let url = format!("{}/api/v1/current-user", config.base_url);
let response = match client
.get(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.send()
.await
{
Ok(res) => res,
Err(e) => {
eprintln!(
"\n{} {}",
style("✘ Network Error:").red().bold(),
"Could not connect to Bichon service."
);
eprintln!("{} {}", style("Details:").dim(), e);
eprintln!(
"\n{} Please check if the Base URL is correct and the server is running.",
style("Tip:").cyan()
);
process::exit(1);
}
};
if !response.status().is_success() {
let status = response.status();
let error_body = response
.text()
.await
.unwrap_or_else(|_| "No error detail provided".to_string());
eprintln!(
"\n{} Server returned an error (Status: {})",
style("✘ API Error:").red().bold(),
style(status).yellow()
);
if status == 401 {
eprintln!(
"{} Your API Token seems to be invalid or expired.",
style("Context:").dim()
);
} else if status == 404 {
eprintln!(
"{} The endpoint was not found. Please check your Base URL.",
style("Context:").dim()
);
}
eprintln!("{} {}", style("Response:").dim(), error_body);
process::exit(1);
}
let user: UserView = response.json().await.expect("Failed to parse user data");
println!("Welcome, {}!", style(&user.username).cyan());
let account_list_url = format!(
"{}/api/v1/minimal-account-list?only_nosync=true",
config.base_url
);
let acc_response = client
.get(&account_list_url)
.header("Authorization", format!("Bearer {}", config.api_token))
.send()
.await
.expect("Failed to fetch account list");
if !acc_response.status().is_success() {
panic!(
"Failed to retrieve accounts. Status: {}",
acc_response.status()
);
}
let accounts: Vec<MinimalAccount> = acc_response
.json()
.await
.expect("Failed to parse minimal account list");
if accounts.is_empty() {
println!(
"\n{}",
style("Error: No 'nosync' accounts found.").red().bold()
);
println!(
"{}",
style("Mail import is only supported for 'nosync' type accounts.").dim()
);
println!(
"Please create a new {} account in the Bichon web interface first.",
style("Nosync").bold().yellow()
);
process::exit(1);
}
let required_permission = Permission::DATA_IMPORT_BATCH;
let mut selectable_accounts = Vec::new();
let mut options = Vec::new();
for acc in accounts {
let has_permission = if let Some(perms) = user.account_permissions.get(&acc.id) {
perms.iter().any(|p| p == required_permission)
} else {
user.global_permissions
.iter()
.any(|p| p == Permission::DATA_MANAGE_ALL || p == Permission::ROOT)
};
let status_prefix = if has_permission {
style(" [READY] ").green()
} else {
style(" [NO PERMISSION] ").red()
};
options.push(format!(
"{}{} - {}",
status_prefix,
style(&acc.email).bold(),
style(format!("ID: {}", acc.id)).dim()
));
selectable_accounts.push((acc, has_permission));
}
let selection = Select::with_theme(theme)
.with_prompt("Select the target account for import")
.items(&options)
.default(0)
.max_length(10)
.interact()
.unwrap();
let (selected_acc, can_import) = &selectable_accounts[selection];
if !*can_import {
eprintln!(
"\n{} You do not have '{}' permission for account {}.",
style("✘ Permission Denied:").red().bold(),
style(required_permission).yellow(),
style(&selected_acc.email).cyan()
);
eprintln!(
"{} Please contact your administrator to upgrade your role for this account.",
style("Tip:").dim()
);
process::exit(1);
}
println!(
"{} Targeting account: {}",
style("").green(),
style(&selected_acc.email).cyan().bold()
);
selected_acc.id
}
+133
View File
@@ -0,0 +1,133 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use console::style;
use dialoguer::{theme::ColorfulTheme, Input};
use mail_parser::MessageParser;
use reqwest::Client;
use crate::{
base64_encode_url_safe,
modules::cli::{sender::send_batch_request, BichonCtlConfig},
};
pub async fn handle_eml_directory_import(
config: &BichonCtlConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let root_str: String = Input::with_theme(theme)
.with_prompt("Enter the ROOT directory to scan for .eml files")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if p.exists() && p.is_dir() {
Ok(())
} else {
Err("Directory not found.")
}
})
.interact_text()
.unwrap();
let root_path = std::path::PathBuf::from(root_str);
let mut tasks: HashMap<String, Vec<PathBuf>> = HashMap::new();
println!(
"{}",
style("🔍 Scanning recursively using std::fs...").dim()
);
if let Err(e) = scan_dir(&root_path, &root_path, &mut tasks) {
eprintln!("Error scanning directory: {}", e);
return;
}
if tasks.is_empty() {
println!("{}", style("No .eml files found.").yellow());
} else {
process_and_upload(config, account_id, tasks).await;
}
}
fn scan_dir(
root: &Path,
current: &Path,
tasks: &mut HashMap<String, Vec<PathBuf>>,
) -> std::io::Result<()> {
if current.is_dir() {
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
scan_dir(root, &path, tasks)?;
} else if path.is_file() {
if path.extension().and_then(|s| s.to_str()) == Some("eml") {
let rel_path = path.strip_prefix(root).unwrap_or(Path::new(""));
let mailbox_name = rel_path
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let folder = if mailbox_name.is_empty() {
"Inbox".to_string()
} else {
mailbox_name
};
tasks.entry(folder).or_insert_with(|| Vec::new()).push(path);
}
}
}
}
Ok(())
}
async fn process_and_upload(
config: &BichonCtlConfig,
account_id: u64,
tasks: HashMap<String, Vec<PathBuf>>,
) {
let client = Client::new();
let batch_size = 50;
for (mailbox, files) in tasks {
println!("\n🚀 Processing mailbox: {}", style(&mailbox).cyan().bold());
let mut current_batch = Vec::new();
for file_path in files {
let body = match fs::read(&file_path) {
Ok(b) => b,
Err(e) => {
eprintln!(
" {} Failed to read file {:?}: {}",
style("").red(),
file_path,
e
);
continue;
}
};
if MessageParser::new().parse(&body).is_some() {
let b64_content = base64_encode_url_safe!(&body);
current_batch.push(b64_content);
if current_batch.len() >= batch_size {
let to_send = current_batch;
current_batch = Vec::with_capacity(batch_size);
send_batch_request(&client, config, account_id, &mailbox, to_send).await;
}
} else {
eprintln!(
" {} Invalid format, skipping: {:?}",
style("").yellow(),
file_path
);
}
}
if !current_batch.is_empty() {
send_batch_request(&client, config, account_id, &mailbox, current_batch).await;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
use std::collections::HashSet;
pub fn determine_folder(labels_raw: &str) -> String {
let mut status_blacklist = HashSet::new();
status_blacklist.insert("Opened");
status_blacklist.insert("Unread");
status_blacklist.insert("Archived");
let all_labels: Vec<&str> = labels_raw
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if all_labels.is_empty() {
return "Unknown".to_string();
}
let filtered: Vec<&str> = all_labels
.iter()
.filter(|&&l| !status_blacklist.contains(l))
.cloned()
.collect();
match filtered.len() {
// Case A: If all labels were status labels, fallback to the first original label
0 => all_labels[0].to_string(),
// Case B: If only one label remains, that's our target destination
1 => filtered[0].to_string(),
// Case C: Multiple labels remain (e.g., ["Inbox", "medium"])
_ => {
// Prioritize custom business labels by excluding generic locations like "Inbox" or "Sent"
let business_label = filtered.iter().find(|&&l| l != "Inbox" && l != "Sent");
match business_label {
// Return the first non-generic label found
Some(label) => label.to_string(),
// If only generic labels remain (e.g., ["Sent", "Inbox"]), pick the first available
None => filtered[0].to_string(),
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::base64_encode_url_safe;
use crate::modules::cli::mbox::gmail::determine_folder;
use crate::modules::cli::mbox::reader::MboxFile;
use crate::modules::cli::sender::send_batch_request;
use crate::modules::cli::BichonCtlConfig;
use console::style;
use dialoguer::{theme::ColorfulTheme, Input};
use dialoguer::{Confirm, Select};
use mail_parser::MessageParser;
use reqwest::Client;
pub mod gmail;
pub mod reader;
pub async fn handle_mbox_single_file_import(
config: &BichonCtlConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let path_str: String = Input::with_theme(theme)
.with_prompt("Enter the path to your SINGLE .mbox file")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if !p.exists() {
return Err("The specified path does not exist.");
}
if !p.is_file() {
return Err("MBOX mode requires a SINGLE file, not a directory.");
}
Ok(())
})
.interact_text()
.unwrap();
let mbox_path = PathBuf::from(path_str);
let options = vec![
"Use labels from mail headers (X-Gmail-Labels)",
"Specify a single target folder for all emails",
];
let selection = Select::with_theme(theme)
.with_prompt("How should we determine the target folder?")
.items(&options)
.default(0)
.interact()
.unwrap();
let target_folder: Option<String> = match selection {
0 => None,
1 => {
let folder: String = Input::with_theme(theme)
.with_prompt("Target folder name")
.default("INBOX".into())
.interact_text()
.unwrap();
Some(folder)
}
_ => unreachable!(),
};
if let Some(ref folder) = target_folder {
println!(
"{}",
style(format!("Mode: Fixed folder ({})", folder)).dim()
);
} else {
println!("{}", style("Mode: Dynamic (header-based)").dim());
}
println!(
"\n{} Ready to process MBOX file: {}",
style("").green(),
style(mbox_path.display()).cyan()
);
if let Ok(meta) = std::fs::metadata(&mbox_path) {
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
println!(
"{}",
style(format!("Processing file: {:.1} MB", size_mb)).dim()
);
}
if Confirm::with_theme(theme)
.with_prompt("Start importing?")
.default(true)
.interact()
.unwrap()
{
run_import(account_id, &mbox_path, config, target_folder).await
}
}
pub async fn run_import(
account_id: u64,
mbox_path: &PathBuf,
config: &BichonCtlConfig,
target_folder: Option<String>,
) {
let client = Client::new();
let mbox = match MboxFile::from_file(mbox_path) {
Ok(mbox) => mbox,
Err(err) => {
println!("Skipping invalid MBOX: {} ({})", mbox_path.display(), err);
return;
}
};
let mut folder_buffers: HashMap<String, Vec<String>> = HashMap::new();
let batch_limit = 50;
println!("Starting import process...");
for e in mbox.iter() {
let body = e.data;
let message = MessageParser::new().parse(body).unwrap();
let folder_name = match target_folder {
Some(ref folder_name) => folder_name.clone(),
None => {
let labels = message
.header("X-Gmail-Labels")
.and_then(|h| h.as_text())
.unwrap_or("Inbox");
determine_folder(labels)
}
};
let b64_eml = base64_encode_url_safe!(&body);
let buffer = folder_buffers
.entry(folder_name.clone())
.or_insert_with(|| Vec::new());
buffer.push(b64_eml);
if buffer.len() >= batch_limit {
let emls_to_send = folder_buffers.remove(&folder_name).unwrap();
send_batch_request(&client, config, account_id, &folder_name, emls_to_send).await;
}
}
for (folder_name, emls) in folder_buffers {
if !emls.is_empty() {
send_batch_request(&client, config, account_id, &folder_name, emls).await;
}
}
println!("{}", style("Import completed successfully!").green().bold());
}
+208
View File
@@ -0,0 +1,208 @@
use memmap2::Mmap;
use std::fs;
use std::io;
use std::path::Path;
pub struct MboxFile {
map: Mmap,
}
impl MboxFile {
pub fn from_file(name: &Path) -> io::Result<Self> {
let file = fs::File::open(name)?;
let metadata = file.metadata()?;
if metadata.len() == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Empty MBOX file",
));
}
let map = unsafe { Mmap::map(&file)? };
Ok(Self { map })
}
pub fn iter(&self) -> MboxReader<'_> {
MboxReader::new(&self.map)
}
}
pub struct Entry<'a> {
pub offset: usize,
pub data: &'a [u8],
}
pub struct MboxReader<'a> {
data: &'a [u8],
len: usize,
scan_pos: usize,
body_start: Option<usize>,
}
impl<'a> MboxReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self {
data,
len: data.len(),
scan_pos: 0,
body_start: None,
}
}
fn is_from_line(&self, i: usize) -> bool {
if i + 5 > self.len {
return false;
}
if i == 0 {
&self.data[0..5] == b"From "
} else {
self.data[i - 1] == b'\n' && &self.data[i..i + 5] == b"From "
}
}
fn skip_from_line(&self, mut i: usize) -> usize {
while i < self.len && self.data[i] != b'\n' {
i += 1;
}
if i < self.len {
i += 1;
}
i
}
}
impl<'a> Iterator for MboxReader<'a> {
type Item = Entry<'a>;
fn next(&mut self) -> Option<Self::Item> {
while self.scan_pos < self.len {
if self.is_from_line(self.scan_pos) {
let from_pos = self.scan_pos;
let body_pos = self.skip_from_line(from_pos);
if let Some(start) = self.body_start {
let entry = Entry {
offset: start,
data: &self.data[start..from_pos],
};
self.body_start = Some(body_pos);
self.scan_pos = body_pos;
return Some(entry);
} else {
self.body_start = Some(body_pos);
self.scan_pos = body_pos;
continue;
}
}
self.scan_pos += 1;
}
if let Some(start) = self.body_start.take() {
return Some(Entry {
offset: start,
data: &self.data[start..self.len],
});
}
None
}
}
#[cfg(test)]
mod tests {
use mail_parser::MessageParser;
use crate::modules::cli::mbox::gmail::determine_folder;
use super::*;
fn collect_entries(data: &[u8]) -> Vec<&[u8]> {
let reader = MboxReader::new(data);
reader.map(|e| e.data).collect()
}
#[test]
fn two_mails() {
let data = b"From a\nmail1\nFrom b\nmail2\n";
let e = collect_entries(data);
assert_eq!(e, vec![b"mail1\n", b"mail2\n"]);
}
#[test]
fn no_trailing_newline() {
let data = b"From a\nmail1";
let e = collect_entries(data);
assert_eq!(e, vec![b"mail1"]);
}
#[test]
fn from_inside_body() {
let data = b"From a\nhello\nFrom is here\nbye\n";
let e = collect_entries(data);
assert_eq!(e.len(), 2);
}
#[test]
fn inline_from_not_separator() {
let data = b"From a\nhello From world\n";
let e = collect_entries(data);
assert_eq!(e.len(), 1);
}
#[test]
fn realistic_mbox() {
let data = b"From a\nH:1\n\nbody1\nFrom b\nH:2\n\nbody2\n";
let e = collect_entries(data);
assert_eq!(e.len(), 2);
}
#[test]
fn empty_body() {
let data = b"From a\nFrom b\nbody\n";
let e = collect_entries(data);
assert_eq!(e[0], b"");
assert_eq!(e[1], b"body\n");
}
#[test]
fn only_from_line() {
let data = b"From a\n";
let e = collect_entries(data);
assert_eq!(e.len(), 1);
assert_eq!(e[0], b"");
}
#[test]
fn windows_newlines() {
let data = b"From a\r\nbody\r\nFrom b\r\nbody2\r\n";
let e = collect_entries(data);
assert_eq!(e.len(), 2);
}
#[test]
fn many_small_mails() {
let mut data = Vec::new();
for _ in 0..1000 {
data.extend_from_slice(b"From a\nx\n");
}
let e = collect_entries(&data);
assert_eq!(e.len(), 1000);
}
#[test]
fn test11() {
let mbox = MboxFile::from_file(Path::new("e:\\test.mbox")).unwrap();
for e in mbox.iter() {
let body = e.data;
let message = MessageParser::new().parse(body).unwrap();
let labels = message.header("X-Gmail-Labels").unwrap().as_text().unwrap();
//println!("offset={} X-Gmail-Labels={:?}", e.offset, labels);
println!(
"X-Gmail-Labels={:?}, determine_folder={}",
labels,
determine_folder(labels)
)
}
}
}
+36
View File
@@ -0,0 +1,36 @@
use clap::Parser;
use serde::{Deserialize, Serialize};
use crate::bichon_version;
pub mod auth;
pub mod eml;
pub mod mbox;
pub mod pst;
pub mod sender;
pub mod thunderbird;
#[derive(Parser, Debug)]
#[command(
name = "bichonctl",
author = "rustmailer",
version = bichon_version!(),
about = "A CLI tool to import email data into Bichon service"
)]
pub struct BichonCli {
/// Path to the configuration file
#[arg(
short,
long,
default_value = "config.toml",
value_name = "FILE",
help = "Sets a custom config file"
)]
pub config: std::path::PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BichonCtlConfig {
pub base_url: String,
pub api_token: String,
}
+45
View File
@@ -0,0 +1,45 @@
use compressed_rtf::*;
use outlook_pst::ltp::prop_context::PropertyValue;
pub fn decode_subject(value: &PropertyValue) -> Option<String> {
match value {
PropertyValue::String8(value) => {
let offset = match value.buffer().first() {
Some(1) => 2,
_ => 0,
};
let buffer: Vec<_> = value
.buffer()
.iter()
.skip(offset)
.map(|&b| u16::from(b))
.collect();
Some(String::from_utf16_lossy(&buffer))
}
PropertyValue::Unicode(value) => {
let offset = match value.buffer().first() {
Some(1) => 2,
_ => 0,
};
Some(String::from_utf16_lossy(&value.buffer()[offset..]))
}
_ => None,
}
}
pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option<String> {
match code_page {
20127 => {
let buffer: Vec<_> = buffer.iter().map(|&b| u16::from(b)).collect();
Some(String::from_utf16_lossy(&buffer))
}
_ => {
let coding = codepage_strings::Coding::new(code_page).ok()?;
Some(coding.decode(buffer).ok()?.to_string())
}
}
}
pub fn decode_rtf_compressed(buffer: &[u8]) -> Option<String> {
decompress_rtf(buffer).ok()
}
+457
View File
@@ -0,0 +1,457 @@
use chrono::{DateTime, TimeZone, Utc};
use dialoguer::theme::ColorfulTheme;
use dialoguer::Input;
use mail_send::mail_builder::headers::text::Text;
use mail_send::mail_builder::MessageBuilder;
use outlook_pst::ltp::prop_context::PropertyValue;
use crate::base64_encode_url_safe;
use crate::modules::cli::pst::encoding::decode_subject;
use crate::modules::cli::sender::send_batch_request;
use crate::modules::cli::BichonCtlConfig;
use dialoguer::Confirm;
use outlook_pst::messaging::attachment::AttachmentProperties;
use outlook_pst::messaging::folder::Folder;
use outlook_pst::messaging::message::{Message, MessageProperties};
use outlook_pst::ndb::node_id::NodeId;
use reqwest::Client;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::rc::Rc;
mod encoding;
#[derive(Debug, Default)]
pub struct EmailMetadata {
pub message_id: Option<String>,
pub subject: Option<String>,
pub from: Option<String>,
pub to: Option<Vec<String>>,
pub cc: Option<Vec<String>>,
pub bcc: Option<Vec<String>>,
pub html: Option<String>,
pub text: Option<String>,
pub in_reply_to: Option<String>,
}
#[derive(Debug, Default)]
pub struct EmailAttachment {
pub name: Option<String>,
pub mime_type: Option<String>,
pub data: Option<Vec<u8>>,
}
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) {
let path_str: String = Input::with_theme(theme)
.with_prompt("Enter the path to your SINGLE .pst file")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if !p.exists() {
return Err("The specified path does not exist.");
}
if !p.is_file() {
return Err("PST mode requires a SINGLE file, not a directory.");
}
let is_pst = p
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("pst"))
.unwrap_or(false);
if !is_pst {
return Err("The selected file must have a .pst extension.");
}
Ok(())
})
.interact_text()
.unwrap();
let pst_path = std::path::PathBuf::from(path_str);
println!(
"\n{} Ready to process PST file: {}",
console::style("").green(),
console::style(pst_path.display()).cyan()
);
if let Ok(meta) = std::fs::metadata(&pst_path) {
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
println!(
"{}",
console::style(format!("PST File Size: {:.1} MB", size_mb)).dim()
);
}
if Confirm::with_theme(theme)
.with_prompt("Start importing emails from this PST?")
.default(true)
.interact()
.unwrap()
{
parse_pst(pst_path, config, account_id).await;
} else {
println!("{}", console::style("Operation cancelled by user.").red());
}
}
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) {
let client = Client::new();
let pst_store = match outlook_pst::open_store(&pst_path) {
Ok(store) => store,
Err(e) => {
println!(
"{} Failed to open PST file: {}",
console::style("").red(),
console::style(format!("{:#?}", e)).dim()
);
return;
}
};
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
Ok(id) => id,
Err(e) => {
println!(
"{} Could not find IPM_SUBTREE (Mailbox Root): {}",
console::style("").red(),
console::style(format!("{:#?}", e)).dim()
);
return;
}
};
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
Ok(folder) => folder,
Err(e) => {
println!(
"{} Failed to open the root mailbox folder: {}",
console::style("").red(),
console::style(format!("{:#?}", e)).dim()
);
return;
}
};
process_folder_recursively(&client, &ipm_subtree_folder, "", config, account_id).await;
}
fn process_folder_recursively<'a>(
client: &'a Client,
folder: &'a Rc<dyn Folder>,
parent_path: &'a str,
config: &'a BichonCtlConfig,
account_id: u64,
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
Box::pin(async move {
let folder_name = folder
.properties()
.display_name()
.unwrap_or_else(|_| "Unknown".to_string());
let current_path = if parent_path.is_empty() {
folder_name
} else {
format!("{}/{}", parent_path, folder_name)
};
println!(
"{} {}",
console::style("📁 Folder:").dim(),
console::style(&current_path).cyan()
);
let mut emls_batch = Vec::new();
if let Some(contents_table) = folder.contents_table() {
for row in contents_table.rows_matrix() {
let store = folder.store().clone();
let entry_id = match store
.properties()
.make_entry_id(NodeId::from(u32::from(row.id())))
{
Ok(id) => id,
Err(e) => {
eprintln!(
" {} Skip row {}: {:?}",
console::style("").yellow(),
row.unique(),
e
);
continue;
}
};
match store.open_message(&entry_id, None) {
Ok(message) => match build_eml_base64(message) {
Some(base64_eml) => emls_batch.push(base64_eml),
None => {}
},
Err(e) => eprintln!(" {} Open error: {:?}", console::style("").yellow(), e),
}
if emls_batch.len() >= 50 {
let batch = emls_batch.clone();
emls_batch.clear();
send_to_bichon(client, config, account_id, &current_path, batch).await;
}
}
}
if !emls_batch.is_empty() {
send_to_bichon(client, config, account_id, &current_path, emls_batch).await;
}
if let Some(hierarchy_table) = folder.hierarchy_table() {
for row in hierarchy_table.rows_matrix() {
let node = NodeId::from(u32::from(row.id()));
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
process_folder_recursively(
client,
&sub_folder,
&current_path,
config,
account_id,
)
.await;
}
}
}
}
})
}
fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
let properties = message.properties();
let mut builder = MessageBuilder::new();
if let Some(sub) = extract_subject(properties) {
builder = builder.subject(sub);
}
if let Some(mid) = extract_string_property(properties, 0x1035) {
builder = builder.message_id(mid);
}
if let Some(irt) = extract_string_property(properties, 0x1042) {
builder = builder.in_reply_to(irt);
}
if let Some(refs) = extract_string_property(properties, 0x1039) {
builder = builder.header("References", Text::new(refs));
}
if let Some(cid_val) = properties.get(0x3013) {
if let PropertyValue::Binary(bin) = cid_val {
builder = builder.header(
"X-Bichon-Conversation-ID",
Text::new(hex::encode(bin.buffer())),
);
}
}
let from = extract_string_property(properties, 0x5D01)
.or_else(|| extract_string_property(properties, 0x5D02));
if let Some(f) = from {
builder = builder.from(f);
}
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
let dt = filetime_to_datetime(filetime).timestamp();
builder = builder.date(dt);
}
let (to, cc, bcc) = extract_recipients_list(&message);
if !to.is_empty() {
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !cc.is_empty() {
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !bcc.is_empty() {
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if let Some(html) = extract_html(properties) {
builder = builder.html_body(html);
}
if let Some(text) = extract_text(properties) {
builder = builder.text_body(text);
}
if let Some(attachment_table) = message.attachment_table() {
for row in attachment_table.rows_matrix() {
let node_id = NodeId::from(u32::from(row.id()));
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
let att_props = attachment.properties();
let name = extract_attachment_string_property(att_props, 0x3707);
let mime = extract_attachment_string_property(att_props, 0x370E)
.unwrap_or_else(|| "application/octet-stream".into());
let cid = extract_attachment_string_property(att_props, 0x3712);
let is_inline = att_props
.get(0x3714)
.and_then(|val| {
if let PropertyValue::Integer32(f) = val {
Some(f)
} else {
None
}
})
.map(|flag| (flag & 0x4) != 0)
.unwrap_or(false);
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
let data = bin.buffer().to_vec();
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
if is_inline && cid.is_some() {
let content_id = cid.unwrap();
builder = builder.inline(mime, content_id, data);
} else {
builder = builder.attachment(mime, file_name, data);
}
}
}
}
}
match builder.write_to_vec() {
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
Err(e) => {
eprintln!("Failed to generate EML: {:?}", e);
None
}
}
}
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
let nsecs = (filetime % 10_000_000) * 100;
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
}
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut to = Vec::new();
let mut cc = Vec::new();
let mut bcc = Vec::new();
let recipient_table = message.recipient_table();
let context = recipient_table.context();
for row in recipient_table.rows_matrix() {
if let Ok(cols) = row.columns(context) {
let mut r_type = 0;
let mut email = String::new();
for (col, val) in context.columns().iter().zip(cols) {
let prop_val = val
.as_ref()
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
match col.prop_id() {
0x0C15 => {
if let Some(PropertyValue::Integer32(t)) = prop_val {
r_type = t;
}
}
0x39FE | 0x3003 => {
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
email = s;
}
}
_ => {}
}
}
if !email.is_empty() {
match r_type {
1 => to.push(email),
2 => cc.push(email),
3 => bcc.push(email),
_ => {}
}
}
}
}
(to, cc, bcc)
}
async fn send_to_bichon(
client: &Client,
config: &BichonCtlConfig,
account_id: u64,
folder_path: &str,
emls: Vec<String>,
) {
send_batch_request(client, config, account_id, folder_path, emls).await;
}
fn extract_subject(props: &MessageProperties) -> Option<String> {
props.get(0x0037).and_then(|val| decode_subject(val))
}
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_attachment_string_property(
properties: &AttachmentProperties,
prop_id: u16,
) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_string(value: &PropertyValue) -> Option<String> {
match value {
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
}
}
fn extract_text(properties: &MessageProperties) -> Option<String> {
properties.get(0x1000).and_then(extract_string).or_else(|| {
properties.get(0x1009).and_then(|value| match value {
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
_ => None,
})
})
}
fn extract_html(properties: &MessageProperties) -> Option<String> {
properties.get(0x1013).and_then(|value| match value {
PropertyValue::Binary(value) => {
let code_page = properties
.get(0x3FDE)
.and_then(|v| {
if let PropertyValue::Integer32(cpid) = v {
Some(*cpid as u16)
} else {
None
}
})
.unwrap_or(65001);
encoding::decode_html_body(value.buffer(), code_page)
}
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
})
}
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
for &prop_id in prop_ids {
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
return Some(*value);
}
}
None
}
+54
View File
@@ -0,0 +1,54 @@
use console::style;
use reqwest::Client;
use crate::modules::{cli::BichonCtlConfig, import::BatchEmlRequest};
pub async fn send_batch_request(
client: &Client,
config: &BichonCtlConfig,
account_id: u64,
folder: &str,
emls: Vec<String>,
) {
let url = format!("{}/api/v1/import", config.base_url);
let payload = BatchEmlRequest {
account_id,
mail_folder: folder.to_string(),
emls,
};
let count = payload.emls.len();
match client
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.json(&payload)
.send()
.await
{
Ok(res) if res.status().is_success() => {
println!(
" {} Sent {} emails to [{}]",
style("").green(),
count,
folder
);
}
Ok(res) => {
eprintln!(
" {} Failed to send to [{}]. Status: {}",
style("").red(),
folder,
res.status()
);
}
Err(e) => {
eprintln!(
" {} Network error on [{}]: {}",
style("").red(),
folder,
e
);
}
}
}
+113
View File
@@ -0,0 +1,113 @@
use std::{collections::HashMap, path::PathBuf};
use crate::modules::cli::{mbox::run_import, BichonCtlConfig};
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
pub async fn handle_thunderbird_import(
config: &BichonCtlConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let root_str: String = Input::with_theme(theme)
.with_prompt("Enter your Thunderbird Mail/ImapMail directory")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if p.exists() && p.is_dir() {
Ok(())
} else {
Err("Directory not found.")
}
})
.interact_text()
.unwrap();
let root_path = std::path::PathBuf::from(&root_str);
println!("{}", style("🔍 Scanning Thunderbird structure...").dim());
let mut mbox_tasks: HashMap<String, PathBuf> = HashMap::new();
fn scan_thunderbird_dir(
root: &std::path::Path,
current: &std::path::Path,
tasks: &mut HashMap<String, PathBuf>,
) {
if let Ok(entries) = std::fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if path.is_dir() {
scan_thunderbird_dir(root, &path, tasks);
} else {
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
match extension {
"msf" | "dat" | "html" | "json" | "txt" | "sqlite" => continue,
_ => {}
}
if file_name == "filterlog.html" || file_name == "msgFilterRules.dat" {
continue;
}
if !extension.is_empty() {
continue;
}
if let Ok(rel) = path.strip_prefix(root) {
let mailbox = rel.to_string_lossy().replace(".sbd", "").replace('\\', "/");
tasks.insert(mailbox, path);
}
}
}
}
}
scan_thunderbird_dir(&root_path, &root_path, &mut mbox_tasks);
if mbox_tasks.is_empty() {
println!(
"{}",
style("No mailboxes found in the specified directory.").yellow()
);
return;
}
println!("\n{}", style("🔍 Scanned Mailboxes:").bold().underlined());
let mut sorted_keys: Vec<_> = mbox_tasks.keys().collect();
sorted_keys.sort();
for name in &sorted_keys {
let path = &mbox_tasks[*name];
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let size_mb = file_size as f64 / 1024.0 / 1024.0;
println!(
" {} {} ({:.2} MB)",
style("").dim(),
style(name).cyan(),
size_mb
);
}
println!();
let prompt = format!("Ready to import {} mailboxes. Proceed?", mbox_tasks.len());
if Confirm::with_theme(theme)
.with_prompt(prompt)
.default(true)
.interact()
.unwrap()
{
for (mailbox_name, mbox_file) in mbox_tasks {
println!("\n🚀 Importing: {}", style(&mailbox_name).cyan().bold());
run_import(account_id, &mbox_file, config, Some(mailbox_name)).await;
}
println!(
"\n{}",
style("✨ All mailboxes imported successfully!")
.green()
.bold()
);
} else {
println!("{}", style("Import cancelled.").yellow());
}
}
+1 -1
View File
@@ -16,13 +16,13 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::BichonResult; use crate::modules::error::BichonResult;
pub mod controller; pub mod controller;
pub mod executors; pub mod executors;
pub mod status; pub mod status;
#[allow(async_fn_in_trait)]
pub trait Initialize { pub trait Initialize {
async fn initialize() -> BichonResult<()>; async fn initialize() -> BichonResult<()>;
} }
+12
View File
@@ -100,6 +100,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
let attachments: Vec<String> = message let attachments: Vec<String> = message
.attachments() .attachments()
.filter(|att| {
let disp = att.content_disposition();
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
let has_filename = att.attachment_name().is_some();
has_filename && !is_inline
})
.filter_map(|att| att.attachment_name()) .filter_map(|att| att.attachment_name())
.map(|name| name.to_string()) .map(|name| name.to_string())
.collect(); .collect();
@@ -195,6 +201,12 @@ pub fn extract_envelope_from_eml(
let attachments: Vec<String> = message let attachments: Vec<String> = message
.attachments() .attachments()
.filter(|att| {
let disp = att.content_disposition();
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
let has_filename = att.attachment_name().is_some();
has_filename && !is_inline
})
.filter_map(|att| att.attachment_name()) .filter_map(|att| att.attachment_name())
.map(|name| name.to_string()) .map(|name| name.to_string())
.collect(); .collect();
-1
View File
@@ -100,7 +100,6 @@ impl ImportEmls {
let total = request.emls.len(); let total = request.emls.len();
for (index, eml_base64) in request.emls.into_iter().enumerate() { for (index, eml_base64) in request.emls.into_iter().enumerate() {
// 1. Decode Base64
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) { let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(e) => { Err(e) => {
+30 -11
View File
@@ -24,7 +24,7 @@ use std::{
time::Duration, time::Duration,
}; };
use crate::modules::message::tags::TagCount; use crate::modules::message::{search::SortBy, tags::TagCount};
use crate::{ use crate::{
modules::{ modules::{
account::migration::AccountModel, account::migration::AccountModel,
@@ -621,6 +621,7 @@ impl EnvelopeIndexManager {
page: u64, page: u64,
page_size: u64, page_size: u64,
desc: bool, desc: bool,
sort_by: SortBy,
) -> BichonResult<DataPage<Envelope>> { ) -> BichonResult<DataPage<Envelope>> {
assert!(page > 0, "Page number must be greater than 0"); assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0"); assert!(page_size > 0, "Page size must be greater than 0");
@@ -653,17 +654,36 @@ impl EnvelopeIndexManager {
} }
let order = if desc { Order::Desc } else { Order::Asc }; let order = if desc { Order::Desc } else { Order::Asc };
let mailbox_docs: Vec<(i64, DocAddress)> = searcher let mailbox_docs: Vec<DocAddress>;
.search(
&query, match sort_by {
&TopDocs::with_limit(page_size as usize) SortBy::DATE => {
.and_offset(offset as usize) let date_docs: Vec<(i64, DocAddress)> = searcher
.order_by_fast_field(F_DATE, order), .search(
) &query,
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; &TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
mailbox_docs = date_docs.into_iter().map(|(_, addr)| addr).collect();
}
SortBy::SIZE => {
let size_docs: Vec<(u64, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_SIZE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
mailbox_docs = size_docs.into_iter().map(|(_, addr)| addr).collect();
}
}
let mut result = Vec::new(); let mut result = Vec::new();
for (_, doc_address) in mailbox_docs { for doc_address in mailbox_docs {
let doc: TantivyDocument = searcher let doc: TantivyDocument = searcher
.doc_async(doc_address) .doc_async(doc_address)
.await .await
@@ -849,7 +869,6 @@ impl EnvelopeIndexManager {
} }
} }
pub async fn top_10_largest_emails( pub async fn top_10_largest_emails(
&self, &self,
accounts: &Option<HashSet<u64>>, accounts: &Option<HashSet<u64>>,
+1 -2
View File
@@ -16,8 +16,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::logger::file::setup_file_logger;
use crate::logger::file::setup_file_logger;
use crate::modules::settings::cli::SETTINGS; use crate::modules::settings::cli::SETTINGS;
use chrono::Local; use chrono::Local;
use std::process; use std::process;
+38
View File
@@ -0,0 +1,38 @@
use crate::modules::{
cache::imap::mailbox::MailBox,
error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
};
pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResult<()> {
let mailbox = MailBox::get(mailbox_id).await?;
let name = mailbox.name;
let delimiter = mailbox.delimiter.unwrap_or("/".to_owned());
let all_mailboxes = MailBox::list_all(account_id).await?;
let prefix = format!("{}{}", name, delimiter);
let ids_to_delete: Vec<u64> = all_mailboxes
.into_iter()
.filter(|m| m.id == mailbox_id || m.name.starts_with(&prefix))
.map(|m| m.id)
.collect();
if ids_to_delete.is_empty() {
return Ok(());
}
for id in &ids_to_delete {
MailBox::delete(*id).await?;
}
ENVELOPE_INDEX_MANAGER
.delete_mailbox_envelopes(account_id, ids_to_delete.clone())
.await?;
EML_INDEX_MANAGER
.delete_mailbox_envelopes(account_id, ids_to_delete)
.await?;
Ok(())
}
+1 -1
View File
@@ -16,5 +16,5 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod delete;
pub mod list; pub mod list;
+12 -2
View File
@@ -18,7 +18,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use poem_openapi::Object; use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
@@ -49,11 +49,20 @@ pub struct SearchFilter {
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
} }
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
pub enum SortBy {
#[default]
DATE,
SIZE,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)] #[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct SearchRequest { pub struct SearchRequest {
filter: SearchFilter, filter: SearchFilter,
page: u64, page: u64,
page_size: u64, page_size: u64,
sort_by: Option<SortBy>,
desc: Option<bool>,
} }
impl SearchRequest { impl SearchRequest {
pub fn validate(&self) -> BichonResult<()> { pub fn validate(&self) -> BichonResult<()> {
@@ -84,7 +93,8 @@ pub async fn search_messages_impl(
request.filter, request.filter,
request.page, request.page,
request.page_size, request.page_size,
true, request.desc.unwrap_or(true),
request.sort_by.unwrap_or(SortBy::DATE),
) )
.await .await
} }
+1
View File
@@ -19,6 +19,7 @@
pub mod account; pub mod account;
pub mod autoconfig; pub mod autoconfig;
pub mod cache; pub mod cache;
pub mod cli;
pub mod common; pub mod common;
pub mod context; pub mod context;
pub mod dashboard; pub mod dashboard;
+4 -1
View File
@@ -221,10 +221,13 @@ impl AccountApi {
)] )]
async fn minimal_accounts_list( async fn minimal_accounts_list(
&self, &self,
only_nosync: Query<Option<bool>>,
context: ClientContext, context: ClientContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> { ) -> ApiResult<Json<Vec<MinimalAccount>>> {
let is_admin = context.user.is_admin().await; let is_admin = context.user.is_admin().await;
let minimal_list = AccountModel::minimal_list().await?; let only_nosync = only_nosync.0.unwrap_or_default();
let minimal_list = AccountModel::minimal_list(only_nosync).await?;
if is_admin { if is_admin {
return Ok(Json(minimal_list)); return Ok(Json(minimal_list));
} }
+29
View File
@@ -18,6 +18,7 @@
use crate::modules::cache::imap::mailbox::MailBox; use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::common::auth::ClientContext; use crate::modules::common::auth::ClientContext;
use crate::modules::mailbox::delete::delete_mailbox_impl;
use crate::modules::mailbox::list::get_account_mailboxes; use crate::modules::mailbox::list::get_account_mailboxes;
use crate::modules::rest::api::ApiTags; use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult; use crate::modules::rest::ApiResult;
@@ -58,4 +59,32 @@ impl MailBoxApi {
let remote = remote.0.unwrap_or(false); let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?)) Ok(Json(get_account_mailboxes(account_id, remote).await?))
} }
/// Deletes a mailbox for the specified account.
///
/// Requires `DATA_DELETE` permission on the target account.
///
/// # Parameters
/// - `account_id`: Account identifier.
/// - `mailbox_id`: Mailbox identifier.
///
#[oai(
path = "/delete-mailbox/:account_id/:mailbox_id",
method = "delete",
operation_id = "delete_mailbox"
)]
async fn delete_mailbox(
&self,
/// The unique identifier of the account.
account_id: Path<u64>,
mailbox_id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context
.require_permission(Some(account_id), Permission::DATA_DELETE)
.await?;
Ok(delete_mailbox_impl(account_id, mailbox_id).await?)
}
} }
+19 -5
View File
@@ -27,7 +27,7 @@ use crate::modules::users::payload::{
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest, RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
}; };
use crate::modules::users::permissions::Permission; use crate::modules::users::permissions::Permission;
use crate::modules::users::role::UserRole; use crate::modules::users::role::{RoleType, UserRole};
use crate::modules::users::view::UserView; use crate::modules::users::view::UserView;
use crate::modules::users::UserModel; use crate::modules::users::UserModel;
use poem::web::Path; use poem::web::Path;
@@ -101,10 +101,7 @@ impl UsersApi {
let roles = UserRole::list_all().await?; let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect(); let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
let users = UserModel::list_all().await?; let users = UserModel::list_all().await?;
let users = users let users = users.into_iter().map(|u| u.to_view(&role_lookup)).collect();
.into_iter()
.map(|u| u.to_view(&role_lookup))
.collect();
Ok(Json(users)) Ok(Json(users))
} }
@@ -214,4 +211,21 @@ impl UsersApi {
Ok(Json(minimal_list)) Ok(Json(minimal_list))
} }
#[oai(
path = "/list-account-roles",
method = "get",
operation_id = "list_account_roles"
)]
async fn list_account_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_VIEW)
.await?;
let all = UserRole::list_all().await?;
Ok(Json(
all.into_iter()
.filter(|r| matches!(r.role_type, RoleType::Account))
.collect(),
))
}
} }
+5 -5
View File
@@ -46,16 +46,16 @@ pub struct Settings {
)] )]
pub bichon_http_port: i32, pub bichon_http_port: i32,
/// The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1). /// The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).
#[clap( #[clap(
long, long,
env, env,
default_value = "0.0.0.0", default_value = "0.0.0.0",
help = "The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1). Required in cluster mode.", help = "The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).",
value_parser = ValueParser::new(|s: &str| { value_parser = ValueParser::new(|s: &str| {
// Ensure the input is a valid IPv4 address // Ensure the input is a valid IPv4 or IPv6 address
if s.parse::<std::net::Ipv4Addr>().is_err() { if s.parse::<std::net::Ipv4Addr>().is_err() && s.parse::<std::net::Ipv6Addr>().is_err() {
return Err("The bind IP address must be a valid IPv4 address.".to_string()); return Err("The bind IP address must be a valid IPv4 or IPv6 address.".to_string());
} }
// If the address is valid, return it // If the address is valid, return it
+2 -2
View File
@@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize};
use crate::modules::{ use crate::modules::{
database::{list_all_impl, manager::DB_MANAGER}, database::{list_all_impl, manager::DB_MANAGER},
error::BichonResult, error::BichonResult,
users::BichonUser, users::UserModel,
}; };
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)] #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
@@ -34,7 +34,7 @@ pub struct MinimalUser {
impl MinimalUser { impl MinimalUser {
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> { pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
let all_users = list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?; let all_users = list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?;
let minimal_list = all_users let minimal_list = all_users
.into_iter() .into_iter()
.map(|user| MinimalUser { .map(|user| MinimalUser {
+18 -7
View File
@@ -469,7 +469,7 @@ impl BichonUserV2 {
delete_impl(DB_MANAGER.meta_db(), move |rw| { delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get() rw.get()
.primary::<BichonUser>(id) .primary::<UserModel>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| { .ok_or_else(|| {
raise_error!( raise_error!(
@@ -500,13 +500,24 @@ impl BichonUserV2 {
let password_changed = request.password.is_some(); let password_changed = request.password.is_some();
// //
let is_default_admin = id == DEFAULT_ADMIN_USER_ID; let is_default_admin = id == DEFAULT_ADMIN_USER_ID;
let is_valid_admin_roles = matches!(
request.global_roles.as_deref(),
Some([role]) if *role == DEFAULT_ADMIN_ROLE_ID
);
if is_default_admin && !is_valid_admin_roles { if is_default_admin {
return Err(raise_error!(format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id), ErrorCode::Forbidden)); if let Some(roles) = request.global_roles.as_deref() {
let is_valid = matches!(
roles,
[role] if *role == DEFAULT_ADMIN_ROLE_ID
);
if !is_valid {
return Err(raise_error!(
format!(
"The role assignments for default admin (id={}) are immutable to ensure system accessibility.",
id
),
ErrorCode::Forbidden
));
}
}
} }
if let Some(username) = &request.username { if let Some(username) = &request.username {
+4 -4
View File
@@ -222,9 +222,9 @@ impl UserCreateRequest {
let username_len = self.username.len(); let username_len = self.username.len();
// 1. Username constraints // 1. Username constraints
if username_len < 5 { if username_len < 3 {
return Err(raise_error!( return Err(raise_error!(
"Username must be at least 5 characters long.".into(), "Username must be at least 3 characters long.".into(),
ErrorCode::InvalidParameter ErrorCode::InvalidParameter
)); ));
} }
@@ -351,9 +351,9 @@ impl UserUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> { pub async fn validate(&self) -> BichonResult<()> {
if let Some(username) = &self.username { if let Some(username) = &self.username {
let len = username.len(); let len = username.len();
if len < 5 || len > 32 { if len < 3 || len > 32 {
return Err(raise_error!( return Err(raise_error!(
"Username must be 5-32 characters.".into(), "Username must be 3-32 characters.".into(),
ErrorCode::InvalidParameter ErrorCode::InvalidParameter
)); ));
} }
+2 -1
View File
@@ -53,7 +53,8 @@ impl Permission {
/// Create, modify, and delete all users and their roles (Admin only). /// Create, modify, and delete all users and their roles (Admin only).
pub const USER_MANAGE: &str = "user:manage"; pub const USER_MANAGE: &str = "user:manage";
/// View the minimal user list and basic profiles (Managers and Admins). /// View the minimal user list, basic user profiles,
/// including visibility into account-level roles (Managers and Admins).
pub const USER_VIEW: &str = "user:view"; pub const USER_VIEW: &str = "user:view";
/// View and revoke all access tokens in the system. /// View and revoke all access tokens in the system.
+2
View File
@@ -39,6 +39,8 @@
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-visually-hidden": "^1.1.0", "@radix-ui/react-visually-hidden": "^1.1.0",
"@react-spring/web": "^10.0.3", "@react-spring/web": "^10.0.3",
+6
View File
@@ -86,6 +86,12 @@ importers:
'@radix-ui/react-toast': '@radix-ui/react-toast':
specifier: ^1.2.2 specifier: ^1.2.2
version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-toggle':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-toggle-group':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-tooltip': '@radix-ui/react-tooltip':
specifier: ^1.1.4 specifier: ^1.1.4
version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+6
View File
@@ -34,4 +34,10 @@ export interface MailboxData {
export const list_mailboxes = async (accountId: number, remote: boolean) => { export const list_mailboxes = async (accountId: number, remote: boolean) => {
const response = await axiosInstance.get<MailboxData[]>(`/api/v1/list-mailboxes/${accountId}?remote=${remote}`); const response = await axiosInstance.get<MailboxData[]>(`/api/v1/list-mailboxes/${accountId}?remote=${remote}`);
return response.data; return response.data;
};
export const delete_mailbox = async (accountId: number, mailboxId: string) => {
const response = await axiosInstance.delete(`/api/v1/delete-mailbox/${accountId}/${mailboxId}`);
return response.data;
}; };
+5
View File
@@ -180,6 +180,11 @@ export const list_minimal_users = async () => {
return response.data; return response.data;
}; };
export const list_account_roles = async () => {
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-account-roles");
return response.data;
};
export const remove_user = async (id: number) => { export const remove_user = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/users/${id}`); const response = await axiosInstance.delete(`/api/v1/users/${id}`);
return response.data; return response.data;
+18 -14
View File
@@ -27,6 +27,7 @@ import {
import { NavGroup } from '@/components/layout/nav-group' import { NavGroup } from '@/components/layout/nav-group'
import Logo from '@/assets/logo.svg' import Logo from '@/assets/logo.svg'
import { useSidebarData } from './data/sidebar-data' import { useSidebarData } from './data/sidebar-data'
import { Link } from '@tanstack/react-router';
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { open } = useSidebar(); const { open } = useSidebar();
@@ -36,22 +37,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarHeader> <SidebarHeader>
<SidebarMenuButton <SidebarMenuButton
size='lg' size='lg'
asChild
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground' className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
> >
<div className='flex aspect-square size-16 items-center justify-center rounded-lg text-sidebar-primary-foreground'> <Link to="/">
<img <div className='flex aspect-square size-16 items-center justify-center rounded-lg text-sidebar-primary-foreground'>
className={open ? "relative ml-[12px] mr-[12px]" : "mr-[30px]"} <img
src={Logo} className={open ? "relative ml-[12px] mr-[12px]" : "mr-[30px]"}
width={open ? 60 : 40} src={Logo}
height={open ? 60 : 40} width={open ? 60 : 40}
alt='Logo' height={open ? 60 : 40}
/> alt='Logo'
</div> />
<div className='grid flex-1 text-left text-lg leading-tight'> </div>
<span className='truncate font-semibold'> <div className='grid flex-1 text-left text-lg leading-tight'>
Bichon <span className='truncate font-semibold'>
</span> Bichon
</div> </span>
</div>
</Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }
@@ -20,7 +20,7 @@ import React from 'react'
import { z } from 'zod' import { z } from 'zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react' import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -52,9 +52,8 @@ import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useToast } from '@/hooks/use-toast' import { useToast } from '@/hooks/use-toast'
import { useRoles } from '@/hooks/use-roles'
import { useMinimalUsers } from '@/hooks/use-minimal-users'
import { access_assign, AccountModel } from '@/api/account/api' import { access_assign, AccountModel } from '@/api/account/api'
import { list_account_roles, list_minimal_users, MinimalUser, UserRole } from '@/api/users/api'
interface Props { interface Props {
currentRow: AccountModel currentRow: AccountModel
@@ -71,12 +70,23 @@ export function AccountAccessAssignmentDialog({
const { toast } = useToast() const { toast } = useToast()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { accountRoles, isLoading: isLoadingRoles } = useRoles() const { data: roles, isLoading: isLoadingRoles } = useQuery<UserRole[]>({
const { users, isLoading: isLoadingUsers } = useMinimalUsers() queryKey: ['account-role-list'],
queryFn: list_account_roles,
staleTime: 5 * 60 * 1000,
enabled: open,
});
const { data: users, isLoading: isLoadingUsers } = useQuery<MinimalUser[]>({
queryKey: ['minimal-user-list'],
queryFn: list_minimal_users,
staleTime: 5 * 60 * 1000,
enabled: open,
});
const [keyword, setKeyword] = React.useState('') const [keyword, setKeyword] = React.useState('')
// 1. 定义校验 Schema (集成国际化错误提示)
const assignmentSchema = z.object({ const assignmentSchema = z.object({
account_ids: z.array(z.number()), account_ids: z.array(z.number()),
user_ids: z.array(z.number()).min(1, { user_ids: z.array(z.number()).min(1, {
@@ -101,7 +111,7 @@ export function AccountAccessAssignmentDialog({
const filteredUsers = React.useMemo(() => { const filteredUsers = React.useMemo(() => {
if (!keyword.trim()) return users if (!keyword.trim()) return users
const lowerKeyword = keyword.toLowerCase() const lowerKeyword = keyword.toLowerCase()
return users.filter( return users!.filter(
(user) => (user) =>
user.username.toLowerCase().includes(lowerKeyword) || user.username.toLowerCase().includes(lowerKeyword) ||
user.email.toLowerCase().includes(lowerKeyword) user.email.toLowerCase().includes(lowerKeyword)
@@ -170,7 +180,7 @@ export function AccountAccessAssignmentDialog({
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{accountRoles.map((role) => ( {roles && roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}> <SelectItem key={role.id} value={role.id.toString()}>
{role.name} {role.name}
</SelectItem> </SelectItem>
@@ -206,12 +216,12 @@ export function AccountAccessAssignmentDialog({
</div> </div>
) : ( ) : (
<div className="p-3 space-y-1"> <div className="p-3 space-y-1">
{filteredUsers.length === 0 ? ( {filteredUsers && (filteredUsers.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground"> <div className="text-center py-8 text-sm text-muted-foreground">
{t('accounts.access_control.user_empty')} {t('accounts.access_control.user_empty')}
</div> </div>
) : ( ) : (
filteredUsers.map((user) => ( filteredUsers!.map((user) => (
<FormField <FormField
key={user.id} key={user.id}
control={form.control} control={form.control}
@@ -242,7 +252,7 @@ export function AccountAccessAssignmentDialog({
)} )}
/> />
)) ))
)} ))}
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
@@ -128,6 +128,7 @@ const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') }) .number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
.int() .int()
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') }) .min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
.nullable()
.optional(), .optional(),
sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }), sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
sync_batch_size: z sync_batch_size: z
@@ -221,7 +222,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const accountSchema = getAccountSchema(isEdit, t); const accountSchema = getAccountSchema(isEdit, t);
const form = useForm<Account>({ const form = useForm<Account>({
mode: "all", mode: "onChange",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues, defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema), resolver: zodResolver(accountSchema),
}); });
@@ -291,9 +292,11 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
}; };
if (isEdit) { if (isEdit) {
const isAllMode = !data.date_since && !data.date_before; const isAllMode = !data.date_since && !data.date_before;
const clear_folder_limit = !data.folder_limit;
updateMutation.mutate({ updateMutation.mutate({
...commonData, ...commonData,
...(isAllMode ? { clear_date_range: true } : {}) ...(isAllMode ? { clear_date_range: true } : {}),
...(clear_folder_limit ? { clear_folder_limit: true } : {})
}); });
} else { } else {
createMutation.mutate({ ...commonData, account_type: "IMAP" }); createMutation.mutate({ ...commonData, account_type: "IMAP" });
@@ -355,7 +358,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
onOpenChange(state); onOpenChange(state);
}} }}
> >
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[90vh]"> <DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[50rem]">
<div className="p-6 pb-2 flex-shrink-0"> <div className="p-6 pb-2 flex-shrink-0">
<DialogHeader className="text-left"> <DialogHeader className="text-left">
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle> <DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
@@ -116,11 +116,11 @@ export function useColumns(): ColumnDef<AccountModel>[] {
cell: ({ row }) => { cell: ({ row }) => {
const { created_user_name, created_user_email } = row.original; const { created_user_name, created_user_email } = row.original;
return ( return (
<div className="flex flex-col py-1 text-center"> <div className="flex flex-col items-center leading-[1.1]">
<span className="text-sm font-medium text-foreground"> <span className="text-[13px] font-medium text-foreground leading-none">
{created_user_name} {created_user_name}
</span> </span>
<span className="text-[11px] text-muted-foreground font-mono"> <span className="text-[11px] text-muted-foreground font-mono leading-none">
{created_user_email} {created_user_email}
</span> </span>
</div> </div>
@@ -82,7 +82,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const form = useForm<NoSyncAccount>({ const form = useForm<NoSyncAccount>({
mode: "all", mode: "onChange",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues, defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema(t)), resolver: zodResolver(accountSchema(t)),
}); });
@@ -85,7 +85,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="text-blue-500 font-medium truncate">{currentRow.email}</span> <span className="text-blue-500 font-medium truncate">{currentRow.email}</span>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<ScrollArea className="max-h-[85vh] px-4 sm:px-6 pb-6"> <ScrollArea className="max-h-[55rem] px-4 sm:px-6 pb-6">
{isLoading && ( {isLoading && (
<div className="space-y-4 py-6"> <div className="space-y-4 py-6">
<Skeleton className="h-6 w-1/2" /> <Skeleton className="h-6 w-1/2" />
@@ -178,7 +178,6 @@ export default function Step3() {
onSelect={(date) => field.onChange(date?.toLocaleDateString('en-CA'))} onSelect={(date) => field.onChange(date?.toLocaleDateString('en-CA'))}
disabled={(date) => date > new Date() || date < new Date("1900-01-01")} disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
locale={dateLocale} locale={dateLocale}
initialFocus
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -244,8 +243,11 @@ export default function Step3() {
<Input <Input
type="number" type="number"
placeholder={t('accounts.folderLimitPlaceholder')} placeholder={t('accounts.folderLimitPlaceholder')}
{...field} value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)} onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? null : Number(value));
}}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
+1 -3
View File
@@ -164,7 +164,7 @@ export default function MailArchiveDashboard() {
return ( return (
<> <>
<FixedHeader /> <FixedHeader />
<Main> <Main higher>
<div className="flex-1 space-y-6 p-6 md:p-8"> <div className="flex-1 space-y-6 p-6 md:p-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -304,7 +304,6 @@ export default function MailArchiveDashboard() {
</Card> </Card>
</TabsContent> </TabsContent>
{/* Attachments */}
<TabsContent value="attachment" className="space-y-4"> <TabsContent value="attachment" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
@@ -459,7 +458,6 @@ export default function MailArchiveDashboard() {
</Tabs> </Tabs>
</div> </div>
{/* Footer / Copyright - New Addition */}
<div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground"> <div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground">
© 2025 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project © 2025 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
</div> </div>
@@ -0,0 +1,105 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { IconAlertTriangle } from '@tabler/icons-react';
import { toast } from '@/hooks/use-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMailboxContext } from '../context';
import { useTranslation } from 'react-i18next';
import { delete_mailbox } from '@/api/mailbox/api';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useMailboxContext();
const { t } = useTranslation();
const deleteMutation = useMutation({
mutationFn: ({ accountId, mailboxId }: { accountId: number; mailboxId: string }) =>
delete_mailbox(accountId, mailboxId),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['account-mailboxes', `${selectedAccountId}`] });
onOpenChange(false);
setDeleteMailboxId(undefined);
toast({
title: t('mailbox.deleteMailboxDialog.successTitle'),
description: t('mailbox.deleteMailboxDialog.successDesc'),
});
},
onError: (error: any) => {
toast({
title: t('mailbox.deleteMailboxDialog.errorTitle'),
description: error.message || "Delete failed",
variant: 'destructive',
});
},
});
const handleDelete = () => {
if (selectedAccountId && deleteMailboxId) {
deleteMutation.mutate({
accountId: selectedAccountId,
mailboxId: deleteMailboxId
});
}
};
const isLoading = deleteMutation.isPending;
return (
<ConfirmDialog
open={open}
onOpenChange={(isOpen) => {
onOpenChange(isOpen);
if (!isOpen) setDeleteMailboxId(undefined);
}}
handleConfirm={handleDelete}
className="max-w-xl"
isLoading={isLoading}
title={
<span className="text-destructive">
<IconAlertTriangle
className="mr-1 inline-block stroke-destructive"
size={18}
/>{' '}
{t('mailbox.deleteMailboxDialog.title')}
</span>
}
desc={
<div className="space-y-4">
<p className="mb-2">
{t('mailbox.deleteMailboxDialog.desc')}
</p>
<Alert variant="destructive">
<AlertTitle>{t('mailbox.deleteMailboxDialog.warningTitle')}</AlertTitle>
<AlertDescription>{t('mailbox.deleteMailboxDialog.warningDesc')}</AlertDescription>
</Alert>
</div>
}
confirmText={t('mailbox.deleteMailboxDialog.confirm')}
destructive
/>
);
}
@@ -111,7 +111,7 @@ export function MailList({
/> />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{selected.size > 0 {selected.size > 0
? `${selected.size} ${t('common.selected')}` ? `${t('search.bulkActions.selected', { count: selected.size })}`
: t('common.selectAll')} : t('common.selectAll')}
</span> </span>
</div> </div>
@@ -19,7 +19,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore } from 'lucide-react'; import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileAudio, FileVideo, FileSpreadsheet, FileArchive, FileCode, FileIcon } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -83,6 +83,35 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines
); );
}; };
const getFileConfig = (mimeType: string) => {
const type = mimeType.toLowerCase();
if (type.includes('pdf')) {
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
}
if (type.includes('image/')) {
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
}
if (type.includes('audio/')) {
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
}
if (type.includes('video/')) {
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
}
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
}
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
}
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
}
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
};
export function MailMessageView({ export function MailMessageView({
envelope, envelope,
showActions = true, showActions = true,
@@ -245,11 +274,24 @@ export function MailMessageView({
const nonInline = attachments.filter((a) => !a.inline); const nonInline = attachments.filter((a) => !a.inline);
return nonInline.length > 0 ? ( return nonInline.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{nonInline.map((attachment, i) => ( {nonInline.map((attachment, i) => {
<div key={i} className="flex items-center"> const { icon, color } = getFileConfig(attachment.file_type);
<div className="flex items-center space-x-8"> return <div key={i} className="flex items-center">
<span className="truncate text-xs">{attachment.filename}</span> <div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
<span className="text-xs px-2 py-1 rounded">[{attachment.file_type}]</span> <div className={`flex-shrink-0 ${color}`}>
{icon}
</div>
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
<span
className="truncate text-xs font-medium text-foreground/90"
title={attachment.filename}
>
{attachment.filename}
</span>
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
{attachment.file_type.split('/').pop()?.toUpperCase()}
</span>
</div>
</div> </div>
<div className="flex items-center space-x-4 ml-auto"> <div className="flex items-center space-x-4 ml-auto">
<span className="text-gray-500 text-xs shrink-0"> <span className="text-gray-500 text-xs shrink-0">
@@ -268,7 +310,7 @@ export function MailMessageView({
)} )}
</div> </div>
</div> </div>
))} })}
</div> </div>
) : ( ) : (
<span className="text-gray-500 text-xs italic"> <span className="text-gray-500 text-xs italic">
+73 -30
View File
@@ -49,8 +49,12 @@ import { styled } from "@mui/material/styles"
import { animated, useSpring } from "@react-spring/web" import { animated, useSpring } from "@react-spring/web"
import { TransitionProps } from "@mui/material/transitions" import { TransitionProps } from "@mui/material/transitions"
import Collapse from "@mui/material/Collapse" import Collapse from "@mui/material/Collapse"
import { FolderIcon } from "lucide-react" import { FolderIcon, MoreVertical, Trash2 } from "lucide-react"
import { RestoreMessageDialog } from "./restore-message-dialog" import { RestoreMessageDialog } from "./restore-message-dialog"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { useTranslation } from "react-i18next"
import { MailBoxDeleteDialog } from "./delete-mailbox-dialog"
interface MailProps { interface MailProps {
@@ -79,15 +83,14 @@ const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessages
}); });
}; };
interface CustomLabelProps { interface CustomLabelProps {
exists?: number; exists?: number;
attributes?: { attr: string; extension: string | null }[], attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode; children: React.ReactNode;
id: string;
icon?: React.ElementType; icon?: React.ElementType;
expandable?: boolean; expandable?: boolean;
onDelete: (id: string) => void;
} }
function CustomLabel({ function CustomLabel({
@@ -95,8 +98,11 @@ function CustomLabel({
exists, exists,
attributes, attributes,
children, children,
id,
onDelete,
...other ...other
}: CustomLabelProps) { }: CustomLabelProps) {
const { t } = useTranslation()
return ( return (
<TreeItemLabel <TreeItemLabel
{...other} {...other}
@@ -109,27 +115,39 @@ function CustomLabel({
<span className="font-medium text-sm text-inherit"> <span className="font-medium text-sm text-inherit">
{children} {children}
</span> </span>
{/* <div className="flex gap-2 ml-auto mr-3 opacity-70 text-xs"> <div className="ml-auto flex items-center">
{attributes?.map((attr) => { <DropdownMenu>
const text = <DropdownMenuTrigger asChild>
attr.attr === 'Extension' <Button
? attr.extension variant="ghost"
: attr.attr; size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
return ( onMouseDown={(e) => e.stopPropagation()}
<span key={attr.attr} className="text-inherit"> onClick={(e) => {
{text} e.stopPropagation();
</span> e.preventDefault();
); }}
})} >
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-20">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
}}
onSelect={(e) => {
e.preventDefault();
onDelete(id);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t('common.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
{exists !== undefined && (
<span
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
>
{exists}
</span>
)} */}
</TreeItemLabel> </TreeItemLabel>
); );
} }
@@ -172,6 +190,8 @@ export function Mail({
const [pageSize, setPageSize] = React.useState(30); const [pageSize, setPageSize] = React.useState(30);
const [deleteIds, setDeleteIds] = React.useState<Set<number>>(() => new Set()); const [deleteIds, setDeleteIds] = React.useState<Set<number>>(() => new Set());
const [selected, setSelected] = React.useState<Set<number>>(() => new Set()); const [selected, setSelected] = React.useState<Set<number>>(() => new Set());
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
const { theme } = useTheme() const { theme } = useTheme()
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({ const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
@@ -225,6 +245,11 @@ export function Mail({
} }
}; };
const handleDeleteClick = (id: string) => {
setDeleteMailboxId(id);
setOpen('delete');
};
const CustomTreeItem = React.useMemo(() => { const CustomTreeItem = React.useMemo(() => {
return React.forwardRef(function CustomTreeItem( return React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps, props: CustomTreeItemProps,
@@ -257,6 +282,8 @@ export function Mail({
<CustomLabel <CustomLabel
{...getLabelProps({ {...getLabelProps({
exists: item.exists, exists: item.exists,
id: item.id,
onDelete: handleDeleteClick,
attributes: item.attributes, attributes: item.attributes,
expandable: status.expandable && status.expanded, expandable: status.expandable && status.expanded,
})} })}
@@ -270,11 +297,22 @@ export function Mail({
}); });
}, [theme]); }, [theme]);
return ( return (
<MailboxProvider value={{ open, setOpen, currentMailbox: selectedMailbox, selectedAccountId, setCurrentMailbox: setSelectedMailbox, currentEnvelope: selectedEvelope, setCurrentEnvelope: setSelectedEvelope, deleteIds, setDeleteIds, selected, setSelected }}> <MailboxProvider value={{
open,
setOpen,
currentMailbox: selectedMailbox,
selectedAccountId,
setCurrentMailbox: setSelectedMailbox,
currentEnvelope: selectedEvelope,
setCurrentEnvelope: setSelectedEvelope,
deleteIds,
setDeleteIds,
selected,
setSelected,
deleteMailboxId,
setDeleteMailboxId
}}>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<ResizablePanelGroup <ResizablePanelGroup
direction="horizontal" direction="horizontal"
@@ -302,7 +340,7 @@ export function Mail({
)} )}
> >
<Separator className="mb-2" /> <Separator className="mb-2" />
<ScrollArea className='h-[50rem] w-full pr-4 -mr-4 py-1'> <ScrollArea className='h-[calc(100vh-8rem)] w-full pr-4 -mr-4 py-1'>
<div> <div>
<AccountSwitcher onAccountSelect={(accountId) => { <AccountSwitcher onAccountSelect={(accountId) => {
localStorage.setItem('mailbox:selectedAccountId', `${accountId}`); localStorage.setItem('mailbox:selectedAccountId', `${accountId}`);
@@ -351,7 +389,7 @@ export function Mail({
</div> </div>
<Separator /> <Separator />
<div className="mt-2"> <div className="mt-2">
<ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'> <ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1'>
<MailList <MailList
isLoading={isMessagesLoading} isLoading={isMessagesLoading}
items={(envelopes?.items ?? []).sort((a, b) => { items={(envelopes?.items ?? []).sort((a, b) => {
@@ -409,6 +447,11 @@ export function Mail({
open={open === 'restore'} open={open === 'restore'}
onOpenChange={() => setOpen('restore')} onOpenChange={() => setOpen('restore')}
/> />
<MailBoxDeleteDialog
key='mailbox-delete'
open={open === 'delete'}
onOpenChange={() => setOpen('delete')}
/>
</MailboxProvider > </MailboxProvider >
) )
+3 -1
View File
@@ -21,7 +21,7 @@ import React from 'react'
import { MailboxData } from '@/api/mailbox/api' import { MailboxData } from '@/api/mailbox/api'
import { EmailEnvelope } from '@/api' import { EmailEnvelope } from '@/api'
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore' export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore' | 'delete'
interface MailboxContextType { interface MailboxContextType {
open: MailboxDialogType | null open: MailboxDialogType | null
@@ -30,6 +30,8 @@ interface MailboxContextType {
currentMailbox: MailboxData | undefined currentMailbox: MailboxData | undefined
currentEnvelope: EmailEnvelope | undefined currentEnvelope: EmailEnvelope | undefined
setCurrentMailbox: React.Dispatch<React.SetStateAction<MailboxData | undefined>> setCurrentMailbox: React.Dispatch<React.SetStateAction<MailboxData | undefined>>
deleteMailboxId: string | undefined,
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
setCurrentEnvelope: React.Dispatch<React.SetStateAction<EmailEnvelope | undefined>> setCurrentEnvelope: React.Dispatch<React.SetStateAction<EmailEnvelope | undefined>>
deleteIds: Set<number> deleteIds: Set<number>
setDeleteIds: React.Dispatch<React.SetStateAction<Set<number>>> setDeleteIds: React.Dispatch<React.SetStateAction<Set<number>>>
-1
View File
@@ -32,7 +32,6 @@ export default function Mailboxes() {
return ( return (
<> <>
{/* ===== Top Heading ===== */}
<FixedHeader /> <FixedHeader />
<Main> <Main>
<Mail <Mail
@@ -148,7 +148,7 @@ export function Oauth2Table({ columns, data }: DataTableProps) {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<DataTablePagination table={table} showSelected={true} showPageSizeSelector={true} /> <DataTablePagination table={table} showPageSizeSelector={true} />
</div> </div>
) )
} }
+60 -7
View File
@@ -26,7 +26,7 @@ import { EnvelopeListPagination } from '@/components/pagination';
import { MailList } from './mail-list'; import { MailList } from './mail-list';
import React from 'react'; import React from 'react';
import { EmailEnvelope } from '@/api'; import { EmailEnvelope } from '@/api';
import { Filter, SearchIcon } from 'lucide-react'; import { ArrowDownWideNarrow, ArrowUpWideNarrow, Filter, SearchIcon } from 'lucide-react';
import { MailDisplayDrawer } from './mail-display-dialog'; import { MailDisplayDrawer } from './mail-display-dialog';
import { EnvelopeDeleteDialog } from './delete-dialog'; import { EnvelopeDeleteDialog } from './delete-dialog';
import SearchProvider, { SearchDialogType } from './context'; import SearchProvider, { SearchDialogType } from './context';
@@ -39,6 +39,8 @@ import { EditTagsDialog } from './add-tag-dialog';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Logo from '@/assets/logo.svg' import Logo from '@/assets/logo.svg'
import { RestoreMessageDialog } from './restore-message-dialog'; import { RestoreMessageDialog } from './restore-message-dialog';
import { Separator } from '@/components/ui/separator';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
export default function Search() { export default function Search() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -56,8 +58,12 @@ export default function Search() {
isFetching, isFetching,
page, page,
pageSize, pageSize,
sortBy,
sortOrder,
setPage, setPage,
setPageSize, setPageSize,
setSortBy,
setSortOrder,
onSubmit, onSubmit,
reset, reset,
filter filter
@@ -121,10 +127,57 @@ export default function Search() {
</div> </div>
</aside> </aside>
<div className="flex-1 min-w-0 space-y-4"> <div className="flex-1 min-w-0 space-y-4">
<Button size="sm" onClick={() => setOpen("search-form")}> <div className="flex flex-row items-center justify-between w-full border-b pb-4">
<SearchIcon className="mr-2 h-4 w-4" /> <Button
{t('common.search')} size="sm"
</Button> variant="default"
onClick={() => setOpen("search-form")}
className="px-4 shadow-sm"
>
<SearchIcon className="mr-2 h-4 w-4" />
{t('common.search')}
</Button>
<div className="flex items-center gap-2 bg-muted/50 p-1 rounded-lg border">
<span className="text-xs font-medium text-muted-foreground px-2">
{t('search.sort')}
</span>
<Separator orientation="vertical" className="h-4" />
<ToggleGroup
type="single"
value={sortBy}
onValueChange={(value) => value && setSortBy(value as "DATE" | "SIZE")}
className="gap-1"
>
<ToggleGroupItem
value="DATE"
size="sm"
className="h-7 px-3 text-xs data-[state=on]:bg-background data-[state=on]:shadow-sm"
>
{t('search.date')}
</ToggleGroupItem>
<ToggleGroupItem
value="SIZE"
size="sm"
className="h-7 px-3 text-xs data-[state=on]:bg-background data-[state=on]:shadow-sm"
>
{t('search.size')}
</ToggleGroupItem>
</ToggleGroup>
<Separator orientation="vertical" className="h-4" />
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-background"
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
>
{sortOrder === "asc" ? (
<ArrowUpWideNarrow className="h-4 w-4 text-primary" />
) : (
<ArrowDownWideNarrow className="h-4 w-4 text-primary" />
)}
</Button>
</div>
</div>
{isLoading && ( {isLoading && (
<Card> <Card>
<CardContent className="py-12"> <CardContent className="py-12">
@@ -151,7 +204,7 @@ export default function Search() {
</p> </p>
</div> </div>
</div>} </div>}
{total > 0 && <ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'> {total > 0 && <ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1'>
<MailList <MailList
isLoading={isLoading} isLoading={isLoading}
items={emails} items={emails}
@@ -207,4 +260,4 @@ export default function Search() {
</Main> </Main>
</> </>
); );
} }
+1 -1
View File
@@ -147,7 +147,7 @@ export function MailList({
/> />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{totalSelected > 0 {totalSelected > 0
? `${totalSelected} ${t('common.selected')}` ? `${t('search.bulkActions.selected', { count: totalSelected })}`
: t('common.selectAll')} : t('common.selectAll')}
</span> </span>
</div> </div>
+50 -11
View File
@@ -19,7 +19,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore } from 'lucide-react'; import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -84,6 +84,34 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines
); );
}; };
const getFileConfig = (mimeType: string) => {
const type = mimeType.toLowerCase();
if (type.includes('pdf')) {
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
}
if (type.includes('image/')) {
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
}
if (type.includes('audio/')) {
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
}
if (type.includes('video/')) {
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
}
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
}
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
}
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
}
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
};
export function MailMessageView({ export function MailMessageView({
envelope, envelope,
showActions = true, showActions = true,
@@ -184,7 +212,6 @@ export function MailMessageView({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header Info */}
{showHeader && <div className="grid gap-1 text-xs"> {showHeader && <div className="grid gap-1 text-xs">
<div className="flex space-x-2"> <div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.account')}:</span> <span className="font-medium text-gray-400">{t('mail.account')}:</span>
@@ -216,7 +243,7 @@ export function MailMessageView({
</div> </div>
)} )}
</div>} </div>}
{/* Action Bar */}
{showActions && ( {showActions && (
<> <>
<div className="flex items-center mt-2 space-x-2"> <div className="flex items-center mt-2 space-x-2">
@@ -257,7 +284,6 @@ export function MailMessageView({
</> </>
)} )}
{showAttachments && <Separator className="my-2" />} {showAttachments && <Separator className="my-2" />}
{/* Attachments */}
{showAttachments && ( {showAttachments && (
<div className="mb-2"> <div className="mb-2">
{loading ? ( {loading ? (
@@ -265,13 +291,27 @@ export function MailMessageView({
) : attachments && attachments.length > 0 ? ( ) : attachments && attachments.length > 0 ? (
(() => { (() => {
const nonInline = attachments.filter((a) => !a.inline); const nonInline = attachments.filter((a) => !a.inline);
return nonInline.length > 0 ? ( return nonInline.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{nonInline.map((attachment, i) => ( {nonInline.map((attachment, i) => {
<div key={i} className="flex items-center"> const { icon, color } = getFileConfig(attachment.file_type);
<div className="flex items-center space-x-8"> return <div key={i} className="flex items-center">
<span className="truncate text-xs">{attachment.filename}</span> <div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
<span className="text-xs px-2 py-1 rounded">[{attachment.file_type}]</span> <div className={`flex-shrink-0 ${color}`}>
{icon}
</div>
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
<span
className="truncate text-xs font-medium text-foreground/90"
title={attachment.filename}
>
{attachment.filename}
</span>
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
{attachment.file_type.split('/').pop()?.toUpperCase()}
</span>
</div>
</div> </div>
<div className="flex items-center space-x-4 ml-auto"> <div className="flex items-center space-x-4 ml-auto">
<span className="text-gray-500 text-xs shrink-0"> <span className="text-gray-500 text-xs shrink-0">
@@ -290,7 +330,7 @@ export function MailMessageView({
)} )}
</div> </div>
</div> </div>
))} })}
</div> </div>
) : ( ) : (
<span className="text-gray-500 text-xs italic"> <span className="text-gray-500 text-xs italic">
@@ -304,7 +344,6 @@ export function MailMessageView({
</div> </div>
)} )}
{showAttachments && <Separator className="mb-2" />} {showAttachments && <Separator className="mb-2" />}
{/* Content */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{loading ? ( {loading ? (
<div className="flex justify-center items-center py-8"> <div className="flex justify-center items-center py-8">
+6 -3
View File
@@ -67,7 +67,7 @@ const getSearchFilterSchema = (t: (key: string) => string) => z.object({
before: z.date().optional(), before: z.date().optional(),
account_id: z.number().optional().or(z.literal("")), account_id: z.number().optional().or(z.literal("")),
mailbox_id: z.number().optional().or(z.literal("")), mailbox_id: z.number().optional().or(z.literal("")),
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large']).optional(), size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large', 'huge']).optional(),
message_id: z.string().optional().or(z.literal("")), message_id: z.string().optional().or(z.literal("")),
}); });
@@ -107,8 +107,10 @@ function withSizePreset(values: Record<string, any>) {
case 'small': case 'small':
return { ...rest, max_size: 2 * 1024 * 1024 }; return { ...rest, max_size: 2 * 1024 * 1024 };
case 'medium': case 'medium':
return { ...rest, max_size: 20 * 1024 * 1024 }; return { ...rest, min_size: 2 * 1024 * 1024, max_size: 10 * 1024 * 1024 };
case 'large': case 'large':
return { ...rest, min_size: 10 * 1024 * 1024, max_size: 20 * 1024 * 1024 };
case 'huge':
return { ...rest, min_size: 20 * 1024 * 1024 }; return { ...rest, min_size: 20 * 1024 * 1024 };
default: default:
return rest; return rest;
@@ -455,6 +457,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
<SelectItem value="small">{t('search.small')}</SelectItem> <SelectItem value="small">{t('search.small')}</SelectItem>
<SelectItem value="medium">{t('search.medium')}</SelectItem> <SelectItem value="medium">{t('search.medium')}</SelectItem>
<SelectItem value="large">{t('search.large')}</SelectItem> <SelectItem value="large">{t('search.large')}</SelectItem>
<SelectItem value="huge">{t('search.huge')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -496,4 +499,4 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
</Form> </Form>
</SheetContent> </SheetContent>
</Sheet>); </Sheet>);
} }
+1 -1
View File
@@ -83,7 +83,7 @@ export function EnvelopeTags({ selectedTags, onTagToggle }: EnvelopeTagsProps) {
{sortedTags.length === 0 ? ( {sortedTags.length === 0 ? (
<p className="py-2 pl-2 text-sm text-muted-foreground">{t('mail.noTagsYet')}</p> <p className="py-2 pl-2 text-sm text-muted-foreground">{t('mail.noTagsYet')}</p>
) : ( ) : (
<ScrollArea className="h-[45rem] w-full pr-4 -mr-4"> <ScrollArea className="h-[calc(100vh-12rem)] w-full pr-4 -mr-4">
{sortedTags.map(({ tag: facet, count }) => { {sortedTags.map(({ tag: facet, count }) => {
const checked = selectedTags.includes(facet); const checked = selectedTags.includes(facet);
const id = `tag-${facet}`; const id = `tag-${facet}`;
@@ -91,7 +91,7 @@ export function APITokens() {
</Button> </Button>
</div> </div>
<ScrollArea className="h-[40rem] w-full pr-4 -mr-4 py-1"> <ScrollArea className="h-[calc(100vh-16rem)] w-full pr-4 -mr-4 py-1">
<TokenCardList tokens={tokens} userId={user.id} /> <TokenCardList tokens={tokens} userId={user.id} />
</ScrollArea> </ScrollArea>
</> </>
+1 -1
View File
@@ -44,7 +44,7 @@ export function Profile() {
} }
return ( return (
<div className="w-full max-w-6xl ml-0 px-4"> <div className="w-full max-w-7xl ml-0 px-4">
<UserProfileForm user={user!} /> <UserProfileForm user={user!} />
</div> </div>
) )
@@ -132,7 +132,7 @@ export function PermissionsDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl w-[90vw] overflow-hidden flex flex-col max-h-[90vh]"> <DialogContent className="max-w-4xl w-[90vw] overflow-hidden flex flex-col max-h-[90vh]">
<DialogHeader className="pb-4 border-b"> <DialogHeader className="pb-4 border-b">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
@@ -273,7 +273,7 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
})} })}
</h2> </h2>
<ScrollArea className="h-[32rem] pr-4"> <ScrollArea className="h-[calc(100vh-16rem)] pr-4">
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{accessibleAccountIds.map((accountId) => { {accessibleAccountIds.map((accountId) => {
const email = getEmailById(accountId) const email = getEmailById(accountId)
@@ -71,7 +71,7 @@ const accountAccessEntry = (t: any) => z.object({
const baseUserSchema = (t: any) => ({ const baseUserSchema = (t: any) => ({
username: z.string() username: z.string()
.min(1, t('users.actions.schema.username_required')) .min(1, t('users.actions.schema.username_required'))
.min(5, t('users.actions.schema.username_min')) .min(3, t('users.actions.schema.username_min'))
.max(32, t('users.actions.schema.username_max')), .max(32, t('users.actions.schema.username_max')),
email: z.string() email: z.string()
.min(1, t('users.actions.schema.email_required')) .min(1, t('users.actions.schema.email_required'))
-51
View File
@@ -1,51 +0,0 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { list_minimal_users, MinimalUser } from '@/api/users/api'
import { useQuery } from '@tanstack/react-query'
export function useMinimalUsers() {
const query = useQuery<MinimalUser[]>({
queryKey: ['minimal-user-list'],
queryFn: list_minimal_users,
staleTime: 5 * 60 * 1000,
})
const users = query.data ?? []
const userMap = users.reduce((map, user) => {
map[user.id] = user
return map
}, {} as Record<number, MinimalUser>)
const getUsername = (id: number) => userMap[id]?.username ?? ''
const getEmail = (id: number) => userMap[id]?.email ?? ''
const getUser = (id: number) => userMap[id] ?? null
const hasUser = (id: number) => !!userMap[id]
return {
...query,
users,
userMap,
getUsername,
getEmail,
getUser,
hasUser,
}
}
+9 -1
View File
@@ -29,6 +29,8 @@ export function useSearchMessages() {
const [filter, setFilter] = useState<Record<string, any>>({}); const [filter, setFilter] = useState<Record<string, any>>({});
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(30); const [pageSize, setPageSize] = useState(30);
const [sortBy, setSortBy] = useState<"DATE" | "SIZE">("DATE");
const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc");
const onSubmit = (cleaned: Record<string, any>) => { const onSubmit = (cleaned: Record<string, any>) => {
if ('has_attachment' in cleaned && cleaned.has_attachment === false) { if ('has_attachment' in cleaned && cleaned.has_attachment === false) {
@@ -59,12 +61,14 @@ export function useSearchMessages() {
error, error,
isFetching, isFetching,
} = useQuery<PaginatedResponse<EmailEnvelope>>({ } = useQuery<PaginatedResponse<EmailEnvelope>>({
queryKey: ['search-messages', filter, page, pageSize], queryKey: ['search-messages', filter, page, pageSize, sortBy, sortOrder],
queryFn: () => queryFn: () =>
search_messages({ search_messages({
filter: filter, filter: filter,
page, page,
page_size: pageSize, page_size: pageSize,
sort_by: sortBy,
desc: sortOrder === "desc"
}), }),
staleTime: 1000, staleTime: 1000,
retry: false, retry: false,
@@ -76,6 +80,10 @@ export function useSearchMessages() {
totalPages: data?.total_pages ?? 1, totalPages: data?.total_pages ?? 1,
pageSize: data?.page_size ?? pageSize, pageSize: data?.page_size ?? pageSize,
setPageSize, setPageSize,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isLoading, isLoading,
isError, isError,
error: error as Error | null, error: error as Error | null,
+51 -53
View File
@@ -5,78 +5,76 @@
@layer base { @layer base {
:root { :root {
--background: 271 54% 99%; --background: 271 8% 98%;
--foreground: 271 56% 5%; --foreground: 271 56% 6%;
--muted: 91 11% 91%; --muted: 271 8% 92%;
--muted-foreground: 91 13% 31%; --muted-foreground: 271 10% 35%;
--popover: 271 54% 99%; --popover: 271 8% 98%;
--popover-foreground: 271 56% 5%; --popover-foreground: 271 56% 6%;
--card: 271 54% 98%; --card: 271 8% 97%;
--card-foreground: 271 56% 4%; --card-foreground: 271 56% 5%;
--border: 271 14% 89%; --border: 271 6% 88%;
--input: 271 14% 89%; --input: 271 6% 88%;
--primary: 271 60% 66%; --primary: 271 60% 66%;
--primary-foreground: 0 0% 0%; --primary-foreground: 0 0% 0%;
--secondary: 91 60% 66%; --secondary: 91 55% 58%;
--secondary-foreground: 91 60% 6%; --secondary-foreground: 91 50% 10%;
--accent: 91 60% 66%; --accent: 91 55% 58%;
--accent-foreground: 91 60% 6%; --accent-foreground: 91 50% 10%;
--destructive: 17 89% 44%; --destructive: 17 85% 48%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--ring: 271 60% 66%; --ring: 271 60% 66%;
--chart-1: 271 60% 66%; --chart-1: 271 60% 66%;
--chart-2: 91 60% 66%; --chart-2: 91 55% 58%;
--chart-3: 91 60% 66%; --chart-3: 91 55% 58%;
--chart-4: 91 60% 69%; --chart-4: 91 55% 62%;
--chart-5: 271 63% 66%; --chart-5: 271 63% 66%;
--radius: 0.5rem; --radius: 0.5rem;
/* Sidebar */ /* Sidebar */
--sidebar-background: 271 54% 99%; --sidebar-background: 271 8% 98%;
--sidebar-foreground: 271 56% 5%; --sidebar-foreground: 271 56% 6%;
--sidebar-primary: 271 60% 66%; --sidebar-primary: 271 60% 66%;
--sidebar-primary-foreground: 0 0% 0%; --sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 91 60% 66%; --sidebar-accent: 91 55% 58%;
--sidebar-accent-foreground: 91 60% 6%; --sidebar-accent-foreground: 91 50% 10%;
--sidebar-border: 271 14% 89%; --sidebar-border: 271 6% 88%;
--sidebar-ring: 271 60% 66%; --sidebar-ring: 271 60% 66%;
} }
.dark { .dark {
--background: 271 46% 3%; --background: 271 20% 10%;
--foreground: 271 10% 99%; --foreground: 271 10% 96%;
--muted: 91 11% 9%; --muted: 271 10% 14%;
--muted-foreground: 91 13% 69%; --muted-foreground: 271 10% 65%;
--popover: 271 46% 3%; --popover: 271 20% 10%;
--popover-foreground: 271 10% 99%; --popover-foreground: 271 10% 96%;
--card: 271 46% 4%; --card: 271 20% 13%;
--card-foreground: 0 0% 100%; --card-foreground: 0 0% 100%;
--border: 271 14% 14%; --border: 271 12% 22%;
--input: 271 14% 14%; --input: 271 12% 22%;
--primary: 271 60% 66%; --primary: 271 50% 66%;
--primary-foreground: 0 0% 0%; --primary-foreground: 0 0% 0%;
--secondary: 91 60% 66%; --secondary: 91 45% 58%;
--secondary-foreground: 91 60% 6%; --secondary-foreground: 91 40% 10%;
--accent: 91 60% 66%; --accent: 91 45% 58%;
--accent-foreground: 91 60% 6%; --accent-foreground: 91 40% 10%;
--destructive: 17 89% 52%; --destructive: 17 85% 55%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--ring: 271 60% 66%; --ring: 271 50% 66%;
--chart-1: 271 60% 66%; --chart-1: 271 50% 66%;
--chart-2: 91 60% 66%; --chart-2: 91 45% 58%;
--chart-3: 91 60% 66%; --chart-3: 91 45% 58%;
--chart-4: 91 60% 69%; --chart-4: 91 45% 62%;
--chart-5: 271 63% 66%; --chart-5: 271 55% 66%;
/* Sidebar */ /* Sidebar */
--sidebar-background: 271 46% 3%; --sidebar-background: 271 20% 10%;
--sidebar-foreground: 271 10% 99%; --sidebar-foreground: 271 10% 96%;
--sidebar-primary: 271 60% 66%; --sidebar-primary: 271 50% 66%;
--sidebar-primary-foreground: 0 0% 0%; --sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 91 60% 66%; --sidebar-accent: 91 45% 58%;
--sidebar-accent-foreground: 91 60% 6%; --sidebar-accent-foreground: 91 40% 10%;
--sidebar-border: 271 14% 14%; --sidebar-border: 271 12% 22%;
--sidebar-ring: 271 60% 66%; --sidebar-ring: 271 50% 66%;
} }
-2
View File
@@ -34,10 +34,8 @@ export function buildTree(items: MailboxData[]): TreeViewBaseItem<ExtendedTreeIt
for (const mb of items) { for (const mb of items) {
if (!mb.name) continue; if (!mb.name) continue;
const delimiter = mb.delimiter ?? '/'; const delimiter = mb.delimiter ?? '/';
const parts = mb.name.split(delimiter); const parts = mb.name.split(delimiter);
let currentFullName = ''; let currentFullName = '';
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "فعّل هذا الخيار فقط إذا كنت تتصل بخادم IMAP يستخدم شهادة TLS صادرة عن CA عام أو شهادة موقعة ذاتياً قد لا يتعرف عليها نظامك. هذا الإعداد يتجاوز عملية التحقق الاعتيادية من الشهادة، وقد يعرض الاتصال لهجمات “رجل في الوسط” — فعّله فقط إذا كنت تدرك المخاطر." "useDangerousDescription": "فعّل هذا الخيار فقط إذا كنت تتصل بخادم IMAP يستخدم شهادة TLS صادرة عن CA عام أو شهادة موقعة ذاتياً قد لا يتعرف عليها نظامك. هذا الإعداد يتجاوز عملية التحقق الاعتيادية من الشهادة، وقد يعرض الاتصال لهجمات “رجل في الوسط” — فعّله فقط إذا كنت تدرك المخاطر."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "حذف مجلد البريد",
"desc": "هل أنت متأكد من رغبتك في حذف مجلد البريد هذا؟ لا يمكن التراجع عن هذا الإجراء.",
"warningTitle": "تحذير",
"warningDesc": "سيؤدي حذف هذا المجلد أيضًا إلى إزالة جميع رسائل البريد الإلكتروني المؤرشفة والمجلدات الفرعية الموجودة بداخله نهائيًا.",
"confirm": "حذف نهائي",
"successTitle": "تم الحذف بنجاح",
"successDesc": "تمت إزالة مجلد البريد ومحتوياته بنجاح.",
"errorTitle": "فشل الحذف"
},
"title": "صندوق البريد", "title": "صندوق البريد",
"folders": "المجلدات", "folders": "المجلدات",
"messages": "الرسائل", "messages": "الرسائل",
@@ -402,8 +412,10 @@
"any": "الكل", "any": "الكل",
"tiny": "صغير جدًا (<15 كيلوبايت)", "tiny": "صغير جدًا (<15 كيلوبايت)",
"small": "صغير (<2 ميغابايت)", "small": "صغير (<2 ميغابايت)",
"medium": "متوسط (<20 ميغابايت)", "medium": "متوسط (2 - 10 ميغابايت)",
"large": "كبير (20 ميغابايت)", "large": "كبير (10 - 20 ميغابايت)",
"huge": "ضخم (≥20 MB)",
"sort": "فرز",
"sizeDescription": "يشير الحجم إلى الحجم الإجمالي للبريد الإلكتروني، بما في ذلك المرفقات.", "sizeDescription": "يشير الحجم إلى الحجم الإجمالي للبريد الإلكتروني، بما في ذلك المرفقات.",
"title": "بحث", "title": "بحث",
"searching": "جارٍ البحث، يرجى الانتظار...", "searching": "جارٍ البحث، يرجى الانتظار...",
@@ -1262,7 +1274,7 @@
"account_required": "يرجى اختيار حساب", "account_required": "يرجى اختيار حساب",
"role_required": "يرجى اختيار دور", "role_required": "يرجى اختيار دور",
"username_required": "اسم المستخدم مطلوب", "username_required": "اسم المستخدم مطلوب",
"username_min": "5 أحرف على الأقل", "username_min": "3 أحرف على الأقل",
"username_max": "32 حرفاً كحد أقصى", "username_max": "32 حرفاً كحد أقصى",
"email_required": "البريد الإلكتروني مطلوب", "email_required": "البريد الإلكتروني مطلوب",
"email_invalid": "يرجى إدخال بريد إلكتروني صالح", "email_invalid": "يرجى إدخال بريد إلكتروني صالح",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Aktivér denne indstilling kun hvis du opretter forbindelse til en IMAPserver, der bruger et offentligt CAcertifikat eller et selvsigneret certifikat, som dit system måske ikke genkender. Denne indstilling omgår standard certificeringsvalidering og kan gøre dig sårbar over for maninthemiddleangreb — aktiver kun hvis du forstår risikoen." "useDangerousDescription": "Aktivér denne indstilling kun hvis du opretter forbindelse til en IMAPserver, der bruger et offentligt CAcertifikat eller et selvsigneret certifikat, som dit system måske ikke genkender. Denne indstilling omgår standard certificeringsvalidering og kan gøre dig sårbar over for maninthemiddleangreb — aktiver kun hvis du forstår risikoen."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Slet postkassemappe",
"desc": "Er du sikker på, at du vil slette denne postkassemappe? Denne handling kan ikke fortrydes.",
"warningTitle": "Advarsel",
"warningDesc": "Sletning af denne mappe vil også permanent fjerne alle arkiverede e-mails og undermapper i den.",
"confirm": "Slet permanent",
"successTitle": "Sletning lykkedes",
"successDesc": "Postkassemappen og dens indhold er blevet fjernet.",
"errorTitle": "Sletning mislykkedes"
},
"title": "Mailboks", "title": "Mailboks",
"folders": "Mapper", "folders": "Mapper",
"messages": "Meddelelser", "messages": "Meddelelser",
@@ -402,8 +412,10 @@
"any": "Alle", "any": "Alle",
"tiny": "Meget lille (<15 KB)", "tiny": "Meget lille (<15 KB)",
"small": "Lille (<2 MB)", "small": "Lille (<2 MB)",
"medium": "Mellem (<20 MB)", "medium": "Mellem (2 - 10 MB)",
"large": "Stor (20 MB)", "large": "Stor (10 - 20 MB)",
"huge": "Kæmpestor (≥20 MB)",
"sort": "Sortér",
"sizeDescription": "Størrelsen henviser til den samlede e-mailstørrelse, inklusive vedhæftede filer.", "sizeDescription": "Størrelsen henviser til den samlede e-mailstørrelse, inklusive vedhæftede filer.",
"title": "Søg", "title": "Søg",
"searching": "Søger, vent venligst...", "searching": "Søger, vent venligst...",
@@ -1262,7 +1274,7 @@
"account_required": "Vælg venligst en konto", "account_required": "Vælg venligst en konto",
"role_required": "Vælg venligst en rolle", "role_required": "Vælg venligst en rolle",
"username_required": "Brugernavn er påkrævet", "username_required": "Brugernavn er påkrævet",
"username_min": "Mindst 5 tegn", "username_min": "Mindst 3 tegn",
"username_max": "Højst 32 tegn", "username_max": "Højst 32 tegn",
"email_required": "E-mail er påkrævet", "email_required": "E-mail er påkrævet",
"email_invalid": "Indtast en gyldig e-mail", "email_invalid": "Indtast en gyldig e-mail",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Aktivieren Sie diese Option nur, wenn Sie sich mit einem IMAPServer verbinden, der ein öffentliches CAZertifikat oder ein selbstsigniertes Zertifikat verwendet, das Ihr System möglicherweise nicht erkennt. Diese Einstellung umgeht die StandardZertifikatsprüfung und kann Sie für ManintheMiddleAngriffe anfällig machen aktivieren Sie nur, wenn Sie die Risiken verstehen." "useDangerousDescription": "Aktivieren Sie diese Option nur, wenn Sie sich mit einem IMAPServer verbinden, der ein öffentliches CAZertifikat oder ein selbstsigniertes Zertifikat verwendet, das Ihr System möglicherweise nicht erkennt. Diese Einstellung umgeht die StandardZertifikatsprüfung und kann Sie für ManintheMiddleAngriffe anfällig machen aktivieren Sie nur, wenn Sie die Risiken verstehen."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Postfachordner löschen",
"desc": "Sind Sie sicher, dass Sie diesen Postfachordner löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"warningTitle": "Warnung",
"warningDesc": "Das Löschen dieses Ordners entfernt auch dauerhaft alle darin enthaltenen archivierten E-Mails und Unterordner.",
"confirm": "Dauerhaft löschen",
"successTitle": "Erfolgreich gelöscht",
"successDesc": "Der Postfachordner und sein Inhalt wurden erfolgreich entfernt.",
"errorTitle": "Löschen fehlgeschlagen"
},
"title": "Postfach", "title": "Postfach",
"folders": "Ordner", "folders": "Ordner",
"messages": "Nachrichten", "messages": "Nachrichten",
@@ -402,8 +412,10 @@
"any": "Beliebig", "any": "Beliebig",
"tiny": "Sehr klein (<15 KB)", "tiny": "Sehr klein (<15 KB)",
"small": "Klein (<2 MB)", "small": "Klein (<2 MB)",
"medium": "Mittel (<20 MB)", "medium": "Mittel (2 - 10 MB)",
"large": "Groß (20 MB)", "large": "Groß (10 - 20 MB)",
"huge": "Riesig (≥20 MB)",
"sort": "Sortieren",
"sizeDescription": "Die Größe bezieht sich auf die gesamte E-Mail inklusive Anhängen.", "sizeDescription": "Die Größe bezieht sich auf die gesamte E-Mail inklusive Anhängen.",
"title": "Suchen", "title": "Suchen",
"searching": "Wird gesucht, bitte warten Sie...", "searching": "Wird gesucht, bitte warten Sie...",
@@ -1262,7 +1274,7 @@
"account_required": "Bitte wählen Sie ein Konto", "account_required": "Bitte wählen Sie ein Konto",
"role_required": "Bitte wählen Sie eine Rolle", "role_required": "Bitte wählen Sie eine Rolle",
"username_required": "Benutzername ist erforderlich", "username_required": "Benutzername ist erforderlich",
"username_min": "Benutzername muss mindestens 5 Zeichen lang sein", "username_min": "Benutzername muss mindestens 3 Zeichen lang sein",
"username_max": "Benutzername darf maximal 32 Zeichen lang sein", "username_max": "Benutzername darf maximal 32 Zeichen lang sein",
"email_required": "E-Mail-Adresse ist erforderlich", "email_required": "E-Mail-Adresse ist erforderlich",
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein", "email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Enable this option only if you are connecting to an IMAP server with a public or self-signed certificate that may not be recognized by your system. Using this setting bypasses standard certificate validation, which can expose you to man-in-the-middle attacks. Only enable if you understand the risks." "useDangerousDescription": "Enable this option only if you are connecting to an IMAP server with a public or self-signed certificate that may not be recognized by your system. Using this setting bypasses standard certificate validation, which can expose you to man-in-the-middle attacks. Only enable if you understand the risks."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Delete Mailbox Folder",
"desc": "Are you sure you want to delete this mailbox folder? This action cannot be undone.",
"warningTitle": "Warning",
"warningDesc": "Deleting this folder will also permanently remove all archived emails and subfolders contained within it.",
"confirm": "Delete Permanently",
"successTitle": "Deleted successfully",
"successDesc": "The mailbox folder and its contents have been successfully removed.",
"errorTitle": "Delete failed"
},
"title": "Mailbox", "title": "Mailbox",
"folders": "Folders", "folders": "Folders",
"messages": "Messages", "messages": "Messages",
@@ -402,8 +412,10 @@
"any": "Any", "any": "Any",
"tiny": "Tiny (<15 KB)", "tiny": "Tiny (<15 KB)",
"small": "Small (<2 MB)", "small": "Small (<2 MB)",
"medium": "Medium (<20 MB)", "medium": "Medium (2 - 10 MB)",
"large": "Large (20 MB)", "large": "Large (10 - 20 MB)",
"huge": "Huge (≥20 MB)",
"sort": "Sort",
"sizeDescription": "The size refers to the total email size, including attachments.", "sizeDescription": "The size refers to the total email size, including attachments.",
"title": "Search", "title": "Search",
"searching": "Searching, please wait…", "searching": "Searching, please wait…",
@@ -1262,7 +1274,7 @@
"account_required": "Please select an account", "account_required": "Please select an account",
"role_required": "Please select a role", "role_required": "Please select a role",
"username_required": "Username is required", "username_required": "Username is required",
"username_min": "Username must be at least 5 characters", "username_min": "Username must be at least 3 characters",
"username_max": "Username cannot exceed 32 characters", "username_max": "Username cannot exceed 32 characters",
"email_required": "Email address is required", "email_required": "Email address is required",
"email_invalid": "Please enter a valid email address", "email_invalid": "Please enter a valid email address",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Activa esta opción solo si te estás conectando a un servidor IMAP que utiliza un certificado público o autofirmado, el cual puede no ser reconocido por tu sistema. Esta opción omite la validación estándar del certificado y puede exponerte a ataques de tipo “maninthemiddle” — actívala solo si entiendes los riesgos." "useDangerousDescription": "Activa esta opción solo si te estás conectando a un servidor IMAP que utiliza un certificado público o autofirmado, el cual puede no ser reconocido por tu sistema. Esta opción omite la validación estándar del certificado y puede exponerte a ataques de tipo “maninthemiddle” — actívala solo si entiendes los riesgos."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Eliminar carpeta de correo",
"desc": "¿Está seguro de que desea eliminar esta carpeta de correo? Esta acción no se puede deshacer.",
"warningTitle": "Advertencia",
"warningDesc": "Eliminar esta carpeta también eliminará permanentemente todos los correos electrónicos archivados y las subcarpetas que contenga.",
"confirm": "Eliminar permanentemente",
"successTitle": "Eliminado con éxito",
"successDesc": "La carpeta de correo y su contenido han sido eliminados correctamente.",
"errorTitle": "Error al eliminar"
},
"title": "Buzón", "title": "Buzón",
"folders": "Carpetas", "folders": "Carpetas",
"messages": "Mensajes", "messages": "Mensajes",
@@ -402,8 +412,10 @@
"any": "Cualquiera", "any": "Cualquiera",
"tiny": "Muy pequeño (<15 KB)", "tiny": "Muy pequeño (<15 KB)",
"small": "Pequeño (<2 MB)", "small": "Pequeño (<2 MB)",
"medium": "Mediano (<20 MB)", "medium": "Mediano (2 - 10 MB)",
"large": "Grande (20 MB)", "large": "Grande (10 - 20 MB)",
"huge": "Muy grande (≥20 MB)",
"sort": "Ordenar",
"sizeDescription": "El tamaño se refiere al tamaño total del correo electrónico, incluidos los archivos adjuntos.", "sizeDescription": "El tamaño se refiere al tamaño total del correo electrónico, incluidos los archivos adjuntos.",
"title": "Buscar", "title": "Buscar",
"searching": "Buscando, por favor espera...", "searching": "Buscando, por favor espera...",
@@ -1262,7 +1274,7 @@
"account_required": "Seleccione una cuenta", "account_required": "Seleccione una cuenta",
"role_required": "Seleccione un rol", "role_required": "Seleccione un rol",
"username_required": "El nombre de usuario es obligatorio", "username_required": "El nombre de usuario es obligatorio",
"username_min": "Mínimo 5 caracteres", "username_min": "Mínimo 3 caracteres",
"username_max": "Máximo 32 caracteres", "username_max": "Máximo 32 caracteres",
"email_required": "El correo es obligatorio", "email_required": "El correo es obligatorio",
"email_invalid": "Ingrese un correo válido", "email_invalid": "Ingrese un correo válido",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Ota tämä vaihtoehto käyttöön vain, jos IMAP-palvelin käyttää julkista CA:ta tai itse allekirjoitettua sertifikaattia, jonka järjestelmä ei tunnista. Tämä ohittaa normaalin sertifikaattitarkistuksen, ja voi altistaa Man-in-the-Middle -hyökkäyksille. Käytä vain, jos ymmärrät riskin." "useDangerousDescription": "Ota tämä vaihtoehto käyttöön vain, jos IMAP-palvelin käyttää julkista CA:ta tai itse allekirjoitettua sertifikaattia, jonka järjestelmä ei tunnista. Tämä ohittaa normaalin sertifikaattitarkistuksen, ja voi altistaa Man-in-the-Middle -hyökkäyksille. Käytä vain, jos ymmärrät riskin."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Poista postilaatikon kansio",
"desc": "Oletko varma, että haluat poistaa tämän postilaatikon kansion? Tätä toimintoa ei voi peruuttaa.",
"warningTitle": "Varoitus",
"warningDesc": "Tämän kansion poistaminen poistaa pysyvästi myös kaikki sen sisältämät arkistoidut sähköpostit ja alikansiot.",
"confirm": "Poista pysyvästi",
"successTitle": "Poisto onnistui",
"successDesc": "Postilaatikon kansio ja sen sisältö on poistettu onnistuneesti.",
"errorTitle": "Poisto epäonnistui"
},
"title": "Sähköposti", "title": "Sähköposti",
"folders": "Kansiot", "folders": "Kansiot",
"messages": "Viestit", "messages": "Viestit",
@@ -402,8 +412,10 @@
"any": "Kaikki", "any": "Kaikki",
"tiny": "Erittäin pieni (<15 KB)", "tiny": "Erittäin pieni (<15 KB)",
"small": "Pieni (<2 MB)", "small": "Pieni (<2 MB)",
"medium": "Keskikokoinen (<20 MB)", "medium": "Keskikokoinen (2 - 10 MB)",
"large": "Suuri (20 MB)", "large": "Suuri (10 - 20 MB)",
"huge": "Valtava (≥20 MB)",
"sort": "Lajittele",
"sizeDescription": "Koko tarkoittaa sähköpostin kokonaiskokoa, liitteet mukaan lukien.", "sizeDescription": "Koko tarkoittaa sähköpostin kokonaiskokoa, liitteet mukaan lukien.",
"title": "Hae", "title": "Hae",
"searching": "Haetaan, odota hetki...", "searching": "Haetaan, odota hetki...",
@@ -1262,7 +1274,7 @@
"account_required": "Valitse tili", "account_required": "Valitse tili",
"role_required": "Valitse rooli", "role_required": "Valitse rooli",
"username_required": "Käyttäjänimi on pakollinen", "username_required": "Käyttäjänimi on pakollinen",
"username_min": "Vähintään 5 merkkiä", "username_min": "Vähintään 3 merkkiä",
"username_max": "Enintään 32 merkkiä", "username_max": "Enintään 32 merkkiä",
"email_required": "Sähköposti on pakollinen", "email_required": "Sähköposti on pakollinen",
"email_invalid": "Anna kelvollinen sähköpostiosoite", "email_invalid": "Anna kelvollinen sähköpostiosoite",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Activez cette option seulement si vous vous connectez à un serveur IMAP utilisant un certificat public ou autosigné que votre système pourrait ne pas reconnaître. Cette option contourne la vérification standard des certificats, ce qui peut vous exposer à des attaques de type hommedumilieu — nactivez que si vous comprenez les risques." "useDangerousDescription": "Activez cette option seulement si vous vous connectez à un serveur IMAP utilisant un certificat public ou autosigné que votre système pourrait ne pas reconnaître. Cette option contourne la vérification standard des certificats, ce qui peut vous exposer à des attaques de type hommedumilieu — nactivez que si vous comprenez les risques."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Supprimer le dossier de messagerie",
"desc": "Êtes-vous sûr de vouloir supprimer ce dossier de messagerie ? Cette action est irréversible.",
"warningTitle": "Avertissement",
"warningDesc": "La suppression de ce dossier supprimera également de façon permanente tous les e-mails archivés et sous-dossiers qu'il contient.",
"confirm": "Supprimer définitivement",
"successTitle": "Suppression réussie",
"successDesc": "Le dossier de messagerie et son contenu ont été supprimés avec succès.",
"errorTitle": "Échec de la suppression"
},
"title": "Boîte aux lettres", "title": "Boîte aux lettres",
"folders": "Dossiers", "folders": "Dossiers",
"messages": "Messages", "messages": "Messages",
@@ -402,8 +412,10 @@
"any": "Toutes", "any": "Toutes",
"tiny": "Très petite (<15 KB)", "tiny": "Très petite (<15 KB)",
"small": "Petite (<2 MB)", "small": "Petite (<2 MB)",
"medium": "Moyenne (<20 MB)", "medium": "Moyenne (2 - 10 MB)",
"large": "Grande (20 MB)", "large": "Grande (10 - 20 MB)",
"huge": "Énorme (≥20 Mo)",
"sort": "Trier",
"sizeDescription": "La taille correspond à la taille totale de le-mail, pièces jointes incluses.", "sizeDescription": "La taille correspond à la taille totale de le-mail, pièces jointes incluses.",
"title": "Recherche", "title": "Recherche",
"searching": "Recherche en cours, veuillez patienter...", "searching": "Recherche en cours, veuillez patienter...",
@@ -1262,7 +1274,7 @@
"account_required": "Veuillez sélectionner un compte", "account_required": "Veuillez sélectionner un compte",
"role_required": "Veuillez sélectionner un rôle", "role_required": "Veuillez sélectionner un rôle",
"username_required": "Le nom d'utilisateur est requis", "username_required": "Le nom d'utilisateur est requis",
"username_min": "Le nom d'utilisateur doit contenir au moins 5 caractères", "username_min": "Le nom d'utilisateur doit contenir au moins 3 caractères",
"username_max": "Le nom d'utilisateur ne peut pas dépasser 32 caractères", "username_max": "Le nom d'utilisateur ne peut pas dépasser 32 caractères",
"email_required": "L'adresse e-mail est requise", "email_required": "L'adresse e-mail est requise",
"email_invalid": "Veuillez saisir une adresse e-mail valide", "email_invalid": "Veuillez saisir une adresse e-mail valide",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Abilita questa opzione solo se ti connetti a un server IMAP che utilizza un certificato pubblico o autofirmato che il tuo sistema potrebbe non riconoscere. Questa impostazione salta la verifica standard del certificato e può esporre la connessione ad attacchi “maninthemiddle” — attivala solo se comprendi i rischi." "useDangerousDescription": "Abilita questa opzione solo se ti connetti a un server IMAP che utilizza un certificato pubblico o autofirmato che il tuo sistema potrebbe non riconoscere. Questa impostazione salta la verifica standard del certificato e può esporre la connessione ad attacchi “maninthemiddle” — attivala solo se comprendi i rischi."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Elimina cartella casella postale",
"desc": "Sei sicuro di voler eliminare questa cartella? L'azione non può essere annullata.",
"warningTitle": "Avvertimento",
"warningDesc": "L'eliminazione di questa cartella rimuoverà permanentemente anche tutte le e-mail archiviate e le sottocartelle in essa contenute.",
"confirm": "Elimina permanentemente",
"successTitle": "Eliminazione completata",
"successDesc": "La cartella e il suo contenuto sono stati rimossi con successo.",
"errorTitle": "Eliminazione fallita"
},
"title": "Posta in arrivo", "title": "Posta in arrivo",
"folders": "Cartelle", "folders": "Cartelle",
"messages": "Messaggi", "messages": "Messaggi",
@@ -402,8 +412,10 @@
"any": "Qualsiasi", "any": "Qualsiasi",
"tiny": "Molto piccolo (<15 KB)", "tiny": "Molto piccolo (<15 KB)",
"small": "Piccolo (<2 MB)", "small": "Piccolo (<2 MB)",
"medium": "Medio (<20 MB)", "medium": "Medio (2 - 10 MB)",
"large": "Grande (20 MB)", "large": "Grande (10 - 20 MB)",
"huge": "Enorme (≥20 MB)",
"sort": "Ordina",
"sizeDescription": "La dimensione indica la dimensione totale dellemail, inclusi gli allegati.", "sizeDescription": "La dimensione indica la dimensione totale dellemail, inclusi gli allegati.",
"title": "Cerca", "title": "Cerca",
"searching": "Ricerca in corso, attendere prego...", "searching": "Ricerca in corso, attendere prego...",
@@ -1262,7 +1274,7 @@
"account_required": "Seleziona un account", "account_required": "Seleziona un account",
"role_required": "Seleziona un ruolo", "role_required": "Seleziona un ruolo",
"username_required": "Il nome utente è obbligatorio", "username_required": "Il nome utente è obbligatorio",
"username_min": "Minimo 5 caratteri", "username_min": "Minimo 3 caratteri",
"username_max": "Massimo 32 caratteri", "username_max": "Massimo 32 caratteri",
"email_required": "L'email è obbligatoria", "email_required": "L'email è obbligatoria",
"email_invalid": "Inserisci un'email valida", "email_invalid": "Inserisci un'email valida",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "このオプションを有効にすると、公開 CA または自己署名証明書を使用している IMAP サーバーに対して、システムが証明書を認識していない場合でも接続できます。ただし、標準の証明書検証をバイパスするため、中間者攻撃 (MITM) のリスクがあり — リスクを理解した上でのみ有効にしてください。" "useDangerousDescription": "このオプションを有効にすると、公開 CA または自己署名証明書を使用している IMAP サーバーに対して、システムが証明書を認識していない場合でも接続できます。ただし、標準の証明書検証をバイパスするため、中間者攻撃 (MITM) のリスクがあり — リスクを理解した上でのみ有効にしてください。"
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "メールボックスフォルダの削除",
"desc": "このメールボックスフォルダを削除してもよろしいですか?この操作は取り消せません。",
"warningTitle": "警告",
"warningDesc": "このフォルダを削除すると、その中に含まれるすべてのアーカイブメールとサブフォルダも永久に削除されます。",
"confirm": "永久に削除",
"successTitle": "削除完了",
"successDesc": "メールボックスフォルダとその内容が正常に削除されました。",
"errorTitle": "削除失敗"
},
"title": "メールボックス", "title": "メールボックス",
"folders": "フォルダー", "folders": "フォルダー",
"messages": "メッセージ", "messages": "メッセージ",
@@ -402,8 +412,10 @@
"any": "指定なし", "any": "指定なし",
"tiny": "極小(15 KB 未満)", "tiny": "極小(15 KB 未満)",
"small": "小(2 MB 未満)", "small": "小(2 MB 未満)",
"medium": "中(20 MB 未満", "medium": "中(2 - 10 MB",
"large": "大(20 MB 以上", "large": "大(10 - 20 MB",
"huge": "巨大 (≥20 MB)",
"sort": "並べ替え",
"sizeDescription": "サイズは添付ファイルを含むメール全体の容量を指します。", "sizeDescription": "サイズは添付ファイルを含むメール全体の容量を指します。",
"title": "検索", "title": "検索",
"searching": "検索中です。お待ちください…", "searching": "検索中です。お待ちください…",
@@ -1262,7 +1274,7 @@
"account_required": "アカウントを選択してください", "account_required": "アカウントを選択してください",
"role_required": "ロールを選択してください", "role_required": "ロールを選択してください",
"username_required": "ユーザー名は必須です", "username_required": "ユーザー名は必須です",
"username_min": "ユーザー名は 5 文字以上である必要があります", "username_min": "ユーザー名は 3 文字以上である必要があります",
"username_max": "ユーザー名は 32 文字以内である必要があります", "username_max": "ユーザー名は 32 文字以内である必要があります",
"email_required": "メールアドレスは必須です", "email_required": "メールアドレスは必須です",
"email_invalid": "有効なメールアドレスを入力してください", "email_invalid": "有効なメールアドレスを入力してください",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "공개 CA 인증서 또는 자체 서명 인증서를 사용하는 IMAP 서버에 연결할 때, 시스템에서 인증서를 신뢰하지 않아도 이 옵션을 켜면 무시할 수 있습니다. 하지만 표준 인증서 검증을 무시하기 때문에 중간자 공격에 노출될 수 있습니다 — 위험을 이해한 경우에만 사용하세요" "useDangerousDescription": "공개 CA 인증서 또는 자체 서명 인증서를 사용하는 IMAP 서버에 연결할 때, 시스템에서 인증서를 신뢰하지 않아도 이 옵션을 켜면 무시할 수 있습니다. 하지만 표준 인증서 검증을 무시하기 때문에 중간자 공격에 노출될 수 있습니다 — 위험을 이해한 경우에만 사용하세요"
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "메일함 폴더 삭제",
"desc": "이 메일함 폴더를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"warningTitle": "경고",
"warningDesc": "이 폴더를 삭제하면 그 안에 포함된 모든 아카이브된 이메일과 하위 폴더도 영구적으로 삭제됩니다.",
"confirm": "영구 삭제",
"successTitle": "삭제 성공",
"successDesc": "메일함 폴더와 그 내용이 성공적으로 제거되었습니다.",
"errorTitle": "삭제 실패"
},
"title": "받은 편지함", "title": "받은 편지함",
"folders": "폴더", "folders": "폴더",
"messages": "메시지", "messages": "메시지",
@@ -402,8 +412,10 @@
"any": "전체", "any": "전체",
"tiny": "아주 작음 (<15 KB)", "tiny": "아주 작음 (<15 KB)",
"small": "작음 (<2 MB)", "small": "작음 (<2 MB)",
"medium": "중간 (<20 MB)", "medium": "중간 (2 - 10 MB)",
"large": "큼 (20 MB)", "large": "큼 (10 - 20 MB)",
"huge": "대용량 (≥20 MB)",
"sort": "정렬",
"sizeDescription": "크기는 첨부 파일을 포함한 이메일 전체 크기를 의미합니다.", "sizeDescription": "크기는 첨부 파일을 포함한 이메일 전체 크기를 의미합니다.",
"title": "검색", "title": "검색",
"searching": "검색 중입니다. 잠시 기다려 주십시오...", "searching": "검색 중입니다. 잠시 기다려 주십시오...",
@@ -1262,7 +1274,7 @@
"account_required": "계정을 선택하세요", "account_required": "계정을 선택하세요",
"role_required": "역할을 선택하세요", "role_required": "역할을 선택하세요",
"username_required": "사용자 이름은 필수입니다", "username_required": "사용자 이름은 필수입니다",
"username_min": "사용자 이름은 5자 이상이어야 합니다", "username_min": "사용자 이름은 3자 이상이어야 합니다",
"username_max": "사용자 이름은 32자를 초과할 수 없습니다", "username_max": "사용자 이름은 32자를 초과할 수 없습니다",
"email_required": "이메일 주소는 필수입니다", "email_required": "이메일 주소는 필수입니다",
"email_invalid": "유효한 이메일 주소를 입력하세요", "email_invalid": "유효한 이메일 주소를 입력하세요",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Schakel deze optie alleen in als je verbinding maakt met een IMAPserver die een openbaar CA of een selfsigned certificaat gebruikt dat door je systeem mogelijk niet wordt vertrouwd. Deze instelling omzeilt standaard certificaatverificatie en kan je blootstellen aan maninthemiddleaanvallen — activeer alleen als je de risicos begrijpt." "useDangerousDescription": "Schakel deze optie alleen in als je verbinding maakt met een IMAPserver die een openbaar CA of een selfsigned certificaat gebruikt dat door je systeem mogelijk niet wordt vertrouwd. Deze instelling omzeilt standaard certificaatverificatie en kan je blootstellen aan maninthemiddleaanvallen — activeer alleen als je de risicos begrijpt."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Postvakmap verwijderen",
"desc": "Weet u zeker dat u deze postvakmap wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"warningTitle": "Waarschuwing",
"warningDesc": "Het verwijderen van deze map zal ook permanent alle gearchiveerde e-mails und submappen erin verwijderen.",
"confirm": "Permanent verwijderen",
"successTitle": "Succesvol verwijderd",
"successDesc": "De postvakmap en de inhoud ervan zijn succesvol verwijderd.",
"errorTitle": "Verwijderen mislukt"
},
"title": "Postvak In", "title": "Postvak In",
"folders": "Mappen", "folders": "Mappen",
"messages": "Berichten", "messages": "Berichten",
@@ -402,8 +412,10 @@
"any": "Alle", "any": "Alle",
"tiny": "Zeer klein (<15 KB)", "tiny": "Zeer klein (<15 KB)",
"small": "Klein (<2 MB)", "small": "Klein (<2 MB)",
"medium": "Middelgroot (<20 MB)", "medium": "Middelgroot (2 - 10 MB)",
"large": "Groot (20 MB)", "large": "Groot (10 - 20 MB)",
"huge": "Zeer groot (≥20 MB)",
"sort": "Sorteren",
"sizeDescription": "De grootte verwijst naar de totale e-mailgrootte, inclusief bijlagen.", "sizeDescription": "De grootte verwijst naar de totale e-mailgrootte, inclusief bijlagen.",
"title": "Zoeken", "title": "Zoeken",
"searching": "Bezig met zoeken, even geduld alstublieft…", "searching": "Bezig met zoeken, even geduld alstublieft…",
@@ -1262,7 +1274,7 @@
"account_required": "Selecteer een account", "account_required": "Selecteer een account",
"role_required": "Selecteer een rol", "role_required": "Selecteer een rol",
"username_required": "Gebruikersnaam is verplicht", "username_required": "Gebruikersnaam is verplicht",
"username_min": "Minimaal 5 tekens", "username_min": "Minimaal 3 tekens",
"username_max": "Maximaal 32 tekens", "username_max": "Maximaal 32 tekens",
"email_required": "E-mail is verplicht", "email_required": "E-mail is verplicht",
"email_invalid": "Voer een geldig e-mailadres in", "email_invalid": "Voer een geldig e-mailadres in",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Aktiver dette alternativet kun hvis IMAPserveren bruker et offentlig CAsertifikat eller et selvsignert sertifikat som systemet ditt ikke gjenkjenner. Innstillingen hopper over vanlig sertifikatvalidering, noe som kan utsette deg for maninthemiddleangrep bruk kun hvis du forstår risikoen." "useDangerousDescription": "Aktiver dette alternativet kun hvis IMAPserveren bruker et offentlig CAsertifikat eller et selvsignert sertifikat som systemet ditt ikke gjenkjenner. Innstillingen hopper over vanlig sertifikatvalidering, noe som kan utsette deg for maninthemiddleangrep bruk kun hvis du forstår risikoen."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Slett postkassemappe",
"desc": "Er du sikker på at du vil slette denne postkassemappen? Denne handlingen kan ikke angres.",
"warningTitle": "Advarsel",
"warningDesc": "Sletting av denne mappen vil også fjerne alle arkiverte e-poster og undermapper i den permanent.",
"confirm": "Slett permanent",
"successTitle": "Sletting fullført",
"successDesc": "Postkassemappen og innholdet er fjernet.",
"errorTitle": "Sletting mislyktes"
},
"title": "Postkasse", "title": "Postkasse",
"folders": "Mapper", "folders": "Mapper",
"messages": "Meldinger", "messages": "Meldinger",
@@ -402,8 +412,10 @@
"any": "Alle", "any": "Alle",
"tiny": "Svært liten (<15 KB)", "tiny": "Svært liten (<15 KB)",
"small": "Liten (<2 MB)", "small": "Liten (<2 MB)",
"medium": "Middels (<20 MB)", "medium": "Middels (2 - 10 MB)",
"large": "Stor (20 MB)", "large": "Stor (10 - 20 MB)",
"huge": "Kjempestor (≥20 MB)",
"sort": "Sorter",
"sizeDescription": "Størrelsen viser til e-postens totale størrelse, inkludert vedlegg.", "sizeDescription": "Størrelsen viser til e-postens totale størrelse, inkludert vedlegg.",
"title": "Søk", "title": "Søk",
"searching": "Søker, vennligst vent…", "searching": "Søker, vennligst vent…",
@@ -1262,7 +1274,7 @@
"account_required": "Vennligst velg en konto", "account_required": "Vennligst velg en konto",
"role_required": "Vennligst velg en rolle", "role_required": "Vennligst velg en rolle",
"username_required": "Brukernavn er påkrevd", "username_required": "Brukernavn er påkrevd",
"username_min": "Minst 5 tegn", "username_min": "Minst 3 tegn",
"username_max": "Maks 32 tegn", "username_max": "Maks 32 tegn",
"email_required": "E-post er påkrevd", "email_required": "E-post er påkrevd",
"email_invalid": "Oppgi en gyldig e-postadresse", "email_invalid": "Oppgi en gyldig e-postadresse",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Włącz tę opcję tylko wtedy, gdy łączysz się z serwerem IMAP z certyfikatem publicznym lub samopodpisanym, który może nie być rozpoznawany przez Twój system. Użycie tego ustawienia pomija standardową walidację certyfikatu, co może narażać na ataki typu man-in-the-middle. Włącz tę opcję tylko wtedy, gdy rozumiesz związane z tym ryzyko." "useDangerousDescription": "Włącz tę opcję tylko wtedy, gdy łączysz się z serwerem IMAP z certyfikatem publicznym lub samopodpisanym, który może nie być rozpoznawany przez Twój system. Użycie tego ustawienia pomija standardową walidację certyfikatu, co może narażać na ataki typu man-in-the-middle. Włącz tę opcję tylko wtedy, gdy rozumiesz związane z tym ryzyko."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Usuń folder skrzynki",
"desc": "Czy na pewno chcesz usunąć ten folder skrzynki? Tej operacji nie można cofnąć.",
"warningTitle": "Ostrzeżenie",
"warningDesc": "Usunięcie tego folderu spowoduje również trwałe usunięcie wszystkich zarchiwizowanych wiadomości e-mail i podfolderów w nim zawartych.",
"confirm": "Usuń na stałe",
"successTitle": "Usunięto pomyślnie",
"successDesc": "Folder skrzynki i jego zawartość zostały pomyślnie usunięte.",
"errorTitle": "Błąd usuwania"
},
"title": "Skrzynka pocztowa", "title": "Skrzynka pocztowa",
"folders": "Foldery", "folders": "Foldery",
"messages": "Wiadomości", "messages": "Wiadomości",
@@ -402,8 +412,10 @@
"any": "Dowolny", "any": "Dowolny",
"tiny": "Bardzo mały (<15 KB)", "tiny": "Bardzo mały (<15 KB)",
"small": "Mały (<2 MB)", "small": "Mały (<2 MB)",
"medium": "Średni (<20 MB)", "medium": "Średni (2 - 10 MB)",
"large": "Duży (20 MB)", "large": "Duży (10 - 20 MB)",
"huge": "Bardzo duży (≥20 MB)",
"sort": "Sortuj",
"sizeDescription": "Rozmiar odnosi się do całkowitego rozmiaru wiadomości e-mail, łącznie z załącznikami.", "sizeDescription": "Rozmiar odnosi się do całkowitego rozmiaru wiadomości e-mail, łącznie z załącznikami.",
"title": "Szukaj", "title": "Szukaj",
"searching": "Wyszukiwanie, proszę czekać...", "searching": "Wyszukiwanie, proszę czekać...",
@@ -1262,7 +1274,7 @@
"account_required": "Wybierz konto", "account_required": "Wybierz konto",
"role_required": "Wybierz rolę", "role_required": "Wybierz rolę",
"username_required": "Nazwa użytkownika jest wymagana", "username_required": "Nazwa użytkownika jest wymagana",
"username_min": "Min. 5 znaków", "username_min": "Min. 3 znaków",
"username_max": "Maks. 32 znaki", "username_max": "Maks. 32 znaki",
"email_required": "E-mail jest wymagany", "email_required": "E-mail jest wymagany",
"email_invalid": "Wprowadź poprawny adres e-mail", "email_invalid": "Wprowadź poprawny adres e-mail",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Ative esta opção apenas se estiver a conectar a um servidor IMAP que use um certificado público ou autoassinado que o seu sistema possa não reconhecer. Esta opção ignora a verificação normal de certificados e pode expor a ligação a ataques maninthemiddle — ative apenas se compreender os riscos." "useDangerousDescription": "Ative esta opção apenas se estiver a conectar a um servidor IMAP que use um certificado público ou autoassinado que o seu sistema possa não reconhecer. Esta opção ignora a verificação normal de certificados e pode expor a ligação a ataques maninthemiddle — ative apenas se compreender os riscos."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Excluir pasta de correio",
"desc": "Tem certeza de que deseja excluir esta pasta de correio? Esta ação não pode ser desfeita.",
"warningTitle": "Aviso",
"warningDesc": "A exclusão desta pasta também removerá permanentemente todos os e-mails arquivados e subpastas nela contidos.",
"confirm": "Excluir permanentemente",
"successTitle": "Excluído com sucesso",
"successDesc": "A pasta de correio e seu conteúdo foram removidos com sucesso.",
"errorTitle": "Falha ao excluir"
},
"title": "Caixa de Entrada", "title": "Caixa de Entrada",
"folders": "Pastas", "folders": "Pastas",
"messages": "Mensagens", "messages": "Mensagens",
@@ -402,8 +412,10 @@
"any": "Qualquer", "any": "Qualquer",
"tiny": "Muito pequeno (<15 KB)", "tiny": "Muito pequeno (<15 KB)",
"small": "Pequeno (<2 MB)", "small": "Pequeno (<2 MB)",
"medium": "Médio (<20 MB)", "medium": "Médio (2 - 10 MB)",
"large": "Grande (20 MB)", "large": "Grande (10 - 20 MB)",
"huge": "Muito grande (≥20 MB)",
"sort": "Ordenar",
"sizeDescription": "O tamanho refere-se ao tamanho total do e-mail, incluindo anexos.", "sizeDescription": "O tamanho refere-se ao tamanho total do e-mail, incluindo anexos.",
"title": "Pesquisa", "title": "Pesquisa",
"searching": "Pesquisando, por favor, aguarde...", "searching": "Pesquisando, por favor, aguarde...",
@@ -1262,7 +1274,7 @@
"account_required": "Selecione uma conta", "account_required": "Selecione uma conta",
"role_required": "Selecione uma função", "role_required": "Selecione uma função",
"username_required": "Nome de usuário é obrigatório", "username_required": "Nome de usuário é obrigatório",
"username_min": "Mínimo 5 caracteres", "username_min": "Mínimo 3 caracteres",
"username_max": "Máximo 32 caracteres", "username_max": "Máximo 32 caracteres",
"email_required": "E-mail é obrigatório", "email_required": "E-mail é obrigatório",
"email_invalid": "Insira um e-mail válido", "email_invalid": "Insira um e-mail válido",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Включите этот параметр только если IMAP‑сервер использует публичный CA‑сертификат или самоподписанный сертификат, который система может не распознавать. Это отключает стандартную проверку сертификатов и может подвергнуть соединение атакам «человек‑посередине» — активируйте только если вы понимаете риски." "useDangerousDescription": "Включите этот параметр только если IMAP‑сервер использует публичный CA‑сертификат или самоподписанный сертификат, который система может не распознавать. Это отключает стандартную проверку сертификатов и может подвергнуть соединение атакам «человек‑посередине» — активируйте только если вы понимаете риски."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Удалить папку почтового ящика",
"desc": "Вы уверены, что хотите удалить эту папку? Это действие невозможно отменить.",
"warningTitle": "Предупреждение",
"warningDesc": "Удаление этой папки также навсегда удалит все заархивированные электронные письма и подпапки, содержащиеся в ней.",
"confirm": "Удалить навсегда",
"successTitle": "Успешно удалено",
"successDesc": "Папка почтового ящика и ее содержимое были успешно удалены.",
"errorTitle": "Ошибка удаления"
},
"title": "Почтовый ящик", "title": "Почтовый ящик",
"folders": "Папки", "folders": "Папки",
"messages": "Сообщения", "messages": "Сообщения",
@@ -402,8 +412,10 @@
"any": "Любой", "any": "Любой",
"tiny": "Очень маленький (<15 КБ)", "tiny": "Очень маленький (<15 КБ)",
"small": "Маленький (<2 МБ)", "small": "Маленький (<2 МБ)",
"medium": "Средний (<20 МБ)", "medium": "Средний (2 - 10 МБ)",
"large": "Большой (20 МБ)", "large": "Большой (10 - 20 МБ)",
"huge": "Огромный (≥20 MB)",
"sort": "Сортировка",
"sizeDescription": "Размер означает общий размер электронного письма, включая вложения.", "sizeDescription": "Размер означает общий размер электронного письма, включая вложения.",
"title": "Поиск", "title": "Поиск",
"searching": "Поиск, пожалуйста, подождите...", "searching": "Поиск, пожалуйста, подождите...",
@@ -1262,7 +1274,7 @@
"account_required": "Выберите аккаунт", "account_required": "Выберите аккаунт",
"role_required": "Выберите роль", "role_required": "Выберите роль",
"username_required": "Имя пользователя обязательно", "username_required": "Имя пользователя обязательно",
"username_min": "Минимум 5 символов", "username_min": "Минимум 3 символов",
"username_max": "Максимум 32 символа", "username_max": "Максимум 32 символа",
"email_required": "Эл. почта обязательна", "email_required": "Эл. почта обязательна",
"email_invalid": "Введите корректный адрес эл. почты", "email_invalid": "Введите корректный адрес эл. почты",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "Aktivera detta alternativ endast om IMAPservern använder ett offentligt eller självsignerat certifikat som inte är betrott av ditt system. Denna inställning kringgår standardverifiering av certifikat, vilket kan utsätta dig för manimittenattacker — slå på endast om du förstår riskerna." "useDangerousDescription": "Aktivera detta alternativ endast om IMAPservern använder ett offentligt eller självsignerat certifikat som inte är betrott av ditt system. Denna inställning kringgår standardverifiering av certifikat, vilket kan utsätta dig för manimittenattacker — slå på endast om du förstår riskerna."
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "Ta bort brevlådemapp",
"desc": "Är du säker på att du vill ta bort den här mappen? Denna åtgärd kan inte ångras.",
"warningTitle": "Varning",
"warningDesc": "Om du tar bort den här mappen raderas även alla arkiverade e-postmeddelanden och undermappar i den permanent.",
"confirm": "Ta bort permanent",
"successTitle": "Borttagen",
"successDesc": "Brevlådemappen och dess innehåll har raderats.",
"errorTitle": "Kunde inte ta bort"
},
"title": "Brevlåda", "title": "Brevlåda",
"folders": "Mappar", "folders": "Mappar",
"messages": "Meddelanden", "messages": "Meddelanden",
@@ -402,8 +412,10 @@
"any": "Alla", "any": "Alla",
"tiny": "Mycket liten (<15 KB)", "tiny": "Mycket liten (<15 KB)",
"small": "Liten (<2 MB)", "small": "Liten (<2 MB)",
"medium": "Medelstor (<20 MB)", "medium": "Medelstor (2 - 10 MB)",
"large": "Stor (20 MB)", "large": "Stor (10 - 20 MB)",
"huge": "Mycket stor (≥20 MB)",
"sort": "Sortera",
"sizeDescription": "Storleken avser e-postens totala storlek, inklusive bilagor.", "sizeDescription": "Storleken avser e-postens totala storlek, inklusive bilagor.",
"title": "Sök", "title": "Sök",
"searching": "Söker, vänligen vänta…", "searching": "Söker, vänligen vänta…",
@@ -1262,7 +1274,7 @@
"account_required": "Välj ett konto", "account_required": "Välj ett konto",
"role_required": "Välj en roll", "role_required": "Välj en roll",
"username_required": "Användarnamn krävs", "username_required": "Användarnamn krävs",
"username_min": "Användarnamnet måste vara minst 5 tecken", "username_min": "Användarnamnet måste vara minst 3 tecken",
"username_max": "Användarnamnet får inte överstiga 32 tecken", "username_max": "Användarnamnet får inte överstiga 32 tecken",
"email_required": "E-postadress krävs", "email_required": "E-postadress krävs",
"email_invalid": "Ange en giltig e-postadress", "email_invalid": "Ange en giltig e-postadress",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "僅當你連線的 IMAP 伺服器使用公開根憑證或自簽憑證,且系統無法驗證該憑證時才啟用此選項。本選項會略過標準憑證驗證流程,可能導致中間人攻擊等安全風險 — 請確認你了解並接受這些風險後再啟用。" "useDangerousDescription": "僅當你連線的 IMAP 伺服器使用公開根憑證或自簽憑證,且系統無法驗證該憑證時才啟用此選項。本選項會略過標準憑證驗證流程,可能導致中間人攻擊等安全風險 — 請確認你了解並接受這些風險後再啟用。"
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "刪除郵箱資料夾",
"desc": "您確定要刪除此郵箱資料夾嗎?此操作無法撤銷。",
"warningTitle": "警告",
"warningDesc": "刪除此資料夾將永久移除其中包含的所有歸檔郵件及子資料夾。",
"confirm": "永久刪除",
"successTitle": "刪除成功",
"successDesc": "郵箱資料夾及其內容已被成功移除。",
"errorTitle": "刪除失敗"
},
"title": "信箱", "title": "信箱",
"folders": "資料夾", "folders": "資料夾",
"messages": "訊息", "messages": "訊息",
@@ -402,8 +412,10 @@
"any": "不限", "any": "不限",
"tiny": "很小(<15 KB", "tiny": "很小(<15 KB",
"small": "小(<2 MB", "small": "小(<2 MB",
"medium": "中(<20 MB", "medium": "中(2 - 10 MB",
"large": "大(20 MB", "large": "大(10 - 20 MB",
"huge": "極大 (≥20 MB)",
"sort": "排序",
"sizeDescription": "大小指的是整封郵件的大小,包含附件。", "sizeDescription": "大小指的是整封郵件的大小,包含附件。",
"title": "搜尋", "title": "搜尋",
"searching": "正在搜尋,請稍候...", "searching": "正在搜尋,請稍候...",
@@ -1262,7 +1274,7 @@
"account_required": "請選擇一個帳戶", "account_required": "請選擇一個帳戶",
"role_required": "請選擇一個角色", "role_required": "請選擇一個角色",
"username_required": "使用者名稱不能為空", "username_required": "使用者名稱不能為空",
"username_min": "使用者名稱長度至少為 5 個字元", "username_min": "使用者名稱長度至少為 3 個字元",
"username_max": "使用者名稱長度不能超過 32 個字元", "username_max": "使用者名稱長度不能超過 32 個字元",
"email_required": "電子郵件地址不能為空", "email_required": "電子郵件地址不能為空",
"email_invalid": "請輸入有效的電子郵件地址", "email_invalid": "請輸入有效的電子郵件地址",
+15 -3
View File
@@ -358,6 +358,16 @@
"useDangerousDescription": "如果你连接的 IMAP 服务器使用公开 CA 或者自签证书,而系统不认可该证书时,启用此选项可绕过标准证书校验。但请注意,这样可能使你的连接容易受到中间人攻击 —— 仅当你完全理解风险时才启用。" "useDangerousDescription": "如果你连接的 IMAP 服务器使用公开 CA 或者自签证书,而系统不认可该证书时,启用此选项可绕过标准证书校验。但请注意,这样可能使你的连接容易受到中间人攻击 —— 仅当你完全理解风险时才启用。"
}, },
"mailbox": { "mailbox": {
"deleteMailboxDialog": {
"title": "删除邮箱文件夹",
"desc": "您确定要删除此邮箱文件夹吗?此操作无法撤销。",
"warningTitle": "警告",
"warningDesc": "删除此文件夹将永久移除其中包含的所有归档邮件及子文件夹。",
"confirm": "永久删除",
"successTitle": "删除成功",
"successDesc": "邮箱文件夹及其内容已被成功移除。",
"errorTitle": "删除失败"
},
"title": "邮箱", "title": "邮箱",
"folders": "文件夹", "folders": "文件夹",
"messages": "消息", "messages": "消息",
@@ -402,8 +412,10 @@
"any": "不限", "any": "不限",
"tiny": "很小(<15 KB", "tiny": "很小(<15 KB",
"small": "小(<2 MB", "small": "小(<2 MB",
"medium": "中(<20 MB", "medium": "中(2 - 10 MB",
"large": "大(20 MB", "large": "大(10 - 20 MB",
"huge": "极大 (≥20 MB)",
"sort": "排序",
"sizeDescription": "大小指的是邮件整体大小,包含附件。", "sizeDescription": "大小指的是邮件整体大小,包含附件。",
"title": "搜索", "title": "搜索",
"searching": "搜索中,请稍候…", "searching": "搜索中,请稍候…",
@@ -1262,7 +1274,7 @@
"account_required": "请选择一个账户", "account_required": "请选择一个账户",
"role_required": "请选择一个角色", "role_required": "请选择一个角色",
"username_required": "用户名不能为空", "username_required": "用户名不能为空",
"username_min": "用户名长度至少为 5 个字符", "username_min": "用户名长度至少为 3 个字符",
"username_max": "用户名长度不能超过 32 个字符", "username_max": "用户名长度不能超过 32 个字符",
"email_required": "邮箱地址不能为空", "email_required": "邮箱地址不能为空",
"email_invalid": "请输入有效的邮箱地址", "email_invalid": "请输入有效的邮箱地址",