mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4f8e674c3 | ||
|
|
dde6b990da | ||
|
|
6b1f843bd5 | ||
|
|
66fd50bc23 | ||
|
|
9daab241b0 | ||
|
|
85d5490834 | ||
|
|
6e984f376c | ||
|
|
7470125a23 | ||
|
|
469d254e2b | ||
|
|
7fde7ee19a | ||
|
|
d543508a23 | ||
|
|
f440069912 | ||
|
|
9f9fc71d16 | ||
|
|
b2a75643da | ||
|
|
37a38a2910 | ||
|
|
8817ed96f6 | ||
|
|
1ee2eade3a | ||
|
|
7afb1e29aa | ||
|
|
715858183d | ||
|
|
407d865a9a | ||
|
|
e708dc524b | ||
|
|
55996f8f9b | ||
|
|
8bb39b4095 | ||
|
|
0a7a22fa7f | ||
|
|
3fa4fe453c | ||
|
|
907e59027d | ||
|
|
3ea330c884 | ||
|
|
d52c5eef3e | ||
|
|
6b6f11e4c1 | ||
|
|
1682f21cf7 | ||
|
|
5406c4322c | ||
|
|
0abaa66a40 | ||
|
|
123260b69d | ||
|
|
c086a3caf5 | ||
|
|
c07e39495a | ||
|
|
41322c8357 | ||
|
|
2f8ba5ad40 | ||
|
|
213c6452a8 | ||
|
|
9c91025c53 | ||
|
|
be934e2b3f | ||
|
|
12e7bb6ba3 | ||
|
|
f66a86392d | ||
|
|
66b595908c | ||
|
|
174d56e7b4 | ||
|
|
7eacfbfb20 | ||
|
|
2802c7ea07 | ||
|
|
f583c3413c | ||
|
|
9bacf3fb7a | ||
|
|
e2fb0ee39e | ||
|
|
1be5fea51a | ||
|
|
2d29a8111b | ||
|
|
0c2e540834 | ||
|
|
e83a00fe39 | ||
|
|
fa70437a62 | ||
|
|
0b866c81ff | ||
|
|
5b884125f7 | ||
|
|
c3a12eafb2 | ||
|
|
c14834abe8 | ||
|
|
51329fb2e1 | ||
|
|
5ad940269f | ||
|
|
f9ceb83293 | ||
|
|
c185ea102c | ||
|
|
da79916396 | ||
|
|
b29c6ea9ff | ||
|
|
7dd722f9b8 | ||
|
|
19b5168960 | ||
|
|
d90943bbfe | ||
|
|
18a0d52c57 | ||
|
|
15dd26228c | ||
|
|
39d8168de5 | ||
|
|
82915e76ba | ||
|
|
6d5953c73b | ||
|
|
61161b5f3b | ||
|
|
286ae16057 | ||
|
|
8b4dc44c07 | ||
|
|
64a66b4f98 | ||
|
|
5a50f5e327 | ||
|
|
bd10e15c65 | ||
|
|
c123ecb24a | ||
|
|
01182dc90d | ||
|
|
7626634863 | ||
|
|
0914bf710e | ||
|
|
3f11c5dbbf | ||
|
|
5d0039cb74 | ||
|
|
a41b5417e3 | ||
|
|
c19f3977ba | ||
|
|
884fdeba10 | ||
|
|
2228e98410 | ||
|
|
d690f57290 | ||
|
|
8a42fcdb4a | ||
|
|
5028061f20 | ||
|
|
a8b3b24d59 | ||
|
|
af0f47c0e3 | ||
|
|
2b10d201ee | ||
|
|
638a93f184 | ||
|
|
40eca89a75 | ||
|
|
f3c46f97b9 | ||
|
|
396383aa97 | ||
|
|
8f331080bf | ||
|
|
4b0d571cf2 | ||
|
|
16f0fad91e | ||
|
|
dda6d77046 | ||
|
|
a273b7f5e1 | ||
|
|
2cba001431 | ||
|
|
54ed2c3c0a | ||
|
|
5e3d0f1c06 | ||
|
|
a9a9b4a85f | ||
|
|
a5ea98f731 | ||
|
|
21c3d2b795 | ||
|
|
c15fe2a503 | ||
|
|
5f013ea173 | ||
|
|
393b7361e8 | ||
|
|
fd35f4be8e | ||
|
|
d2936ed4a7 | ||
|
|
ef4ab3496e | ||
|
|
2295585deb | ||
|
|
57afa30b5b | ||
|
|
ba8ecdd899 | ||
|
|
673e593c4f | ||
|
|
579822762f | ||
|
|
d63b1e0d7c | ||
|
|
a0d8d069c0 | ||
|
|
bdbbc04832 | ||
|
|
fed28c3eca | ||
|
|
01dba4f71b | ||
|
|
1fde24b3db | ||
|
|
e29e8d76b2 | ||
|
|
62532e2740 | ||
|
|
0fc99aa172 | ||
|
|
852ae2b782 | ||
|
|
0fb79c9a8b | ||
|
|
0dad25c993 | ||
|
|
ee7ea3872f | ||
|
|
a440479946 | ||
|
|
ecb81ac344 | ||
|
|
451b5338f1 | ||
|
|
ddd93ff4ab | ||
|
|
0bfe379310 | ||
|
|
e47a81d510 | ||
|
|
50bdf691dc | ||
|
|
11be1e4758 | ||
|
|
694e5ecbec | ||
|
|
579801ef5c | ||
|
|
feedb91225 | ||
|
|
57a6e3c62e | ||
|
|
f7fe1f3072 | ||
|
|
8e25b0da14 | ||
|
|
e1f471b8f4 | ||
|
|
fcd19b1c9f | ||
|
|
df1a6f8c5b | ||
|
|
7d150c2982 | ||
|
|
9b49005522 | ||
|
|
1f4e9f7b06 | ||
|
|
d0e4cac229 | ||
|
|
30a8d856c1 | ||
|
|
7240be8c31 | ||
|
|
0d20a9676a | ||
|
|
48f8092b5a | ||
|
|
a64351d409 | ||
|
|
a6dd1d19ff | ||
|
|
ad2a43aa35 | ||
|
|
884ae64fc5 | ||
|
|
254de35f0e |
@@ -8,6 +8,12 @@ assignees: ""
|
|||||||
|
|
||||||
> **Please write and communicate in English.**
|
> **Please write and communicate in English.**
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
**Help us build a more stable Bichon!** 🛠️
|
||||||
|
While we look into this bug, consider sharing your usage patterns in our [2026 Roadmap Survey](https://docs.google.com/forms/d/e/1FAIpQLScOlwsiUMfyQPBCLW2MLkygdRmAutEgvXDYPzzvEGPz0HFPXQ/viewform) to help us prioritize stability and features.
|
||||||
|
---
|
||||||
|
|
||||||
### Version
|
### Version
|
||||||
Which version are you using?
|
Which version are you using?
|
||||||
|
|
||||||
7
.github/ISSUE_TEMPLATE/feature.md
vendored
7
.github/ISSUE_TEMPLATE/feature.md
vendored
@@ -8,6 +8,13 @@ assignees: ""
|
|||||||
|
|
||||||
> **Please write and communicate in English.**
|
> **Please write and communicate in English.**
|
||||||
|
|
||||||
|
---
|
||||||
|
### 📢 Shape the Future of Bichon
|
||||||
|
**Want your requested feature to be prioritized?** Help us shape the **2026 Roadmap** by filling out our 1-minute survey:
|
||||||
|
👉 **[Bichon User Survey](https://docs.google.com/forms/d/e/1FAIpQLScOlwsiUMfyQPBCLW2MLkygdRmAutEgvXDYPzzvEGPz0HFPXQ/viewform)**
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
### Description
|
### Description
|
||||||
What feature would you like to see?
|
What feature would you like to see?
|
||||||
|
|
||||||
|
|||||||
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@@ -5,8 +5,9 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- '[0-9]+.[0-9]+.[0-9]+'
|
- '[0-9]+.[0-9]+.[0-9]+'
|
||||||
env:
|
env:
|
||||||
BINARY_NAME: bichon
|
BINARY_NAME: bichon-server
|
||||||
BINARY_CTL: bichonctl
|
BINARY_CLI: bichon-cli
|
||||||
|
BINARY_ADMIN: bichon-admin
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -81,18 +82,19 @@ jobs:
|
|||||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
run: |
|
run: |
|
||||||
cargo install cross --force
|
cargo install cross --force
|
||||||
cross build --release --features vendored-openssl --target=${{ matrix.target }}
|
cross build --release --target=${{ matrix.target }}
|
||||||
|
|
||||||
- name: Build Rust backend
|
- name: Build Rust backend
|
||||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --features vendored-openssl --target=${{ matrix.target }}
|
cargo build --release --target=${{ matrix.target }}
|
||||||
|
|
||||||
- name: Strip binary (Linux and macOS)
|
- name: Strip binary (Linux and macOS)
|
||||||
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 }}
|
strip target/${{ matrix.target }}/release/${{ env.BINARY_CLI }}
|
||||||
|
strip target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}
|
||||||
|
|
||||||
- name: Pack artifact (Linux/macOS)
|
- name: Pack artifact (Linux/macOS)
|
||||||
if: matrix.os != 'windows-latest'
|
if: matrix.os != 'windows-latest'
|
||||||
@@ -102,7 +104,8 @@ jobs:
|
|||||||
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 target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} release/
|
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} release/
|
||||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_CTL }} release/
|
cp target/${{ matrix.target }}/release/${{ env.BINARY_CLI }} release/
|
||||||
|
cp target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }} 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/
|
||||||
|
|
||||||
@@ -120,7 +123,8 @@ jobs:
|
|||||||
mkdir -p release
|
mkdir -p release
|
||||||
|
|
||||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" 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 "target/${{ matrix.target }}/release/${{ env.BINARY_CLI }}.exe" release/
|
||||||
|
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}.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/
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
config.toml
|
||||||
3666
Cargo.lock
generated
3666
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
138
Cargo.toml
138
Cargo.toml
@@ -1,52 +1,34 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "bichon"
|
|
||||||
version = "0.3.1"
|
members = [
|
||||||
|
"crates/memdb",
|
||||||
|
"crates/core",
|
||||||
|
"crates/server",
|
||||||
|
"crates/cli",
|
||||||
|
"crates/admin",
|
||||||
|
]
|
||||||
|
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "1.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[workspace.dependencies]
|
||||||
name = "bichon"
|
chrono = "0.4.44"
|
||||||
path = "src/main.rs"
|
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||||
|
mimalloc = "0.1.50"
|
||||||
[[bin]]
|
memdb = { path = "crates/memdb" }
|
||||||
name = "bichonctl"
|
|
||||||
path = "src/bin/bichonctl.rs"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
vendored-openssl = ["openssl-sys"]
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
strip = true
|
|
||||||
lto = true
|
|
||||||
opt-level = 3
|
|
||||||
codegen-units = 1
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
chrono = "0.4.42"
|
|
||||||
clap = { version = "4.5.54", features = ["derive", "env"] }
|
|
||||||
mimalloc = "0.1.48"
|
|
||||||
native_db = "0.8.2"
|
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
native_model = "0.4.20"
|
|
||||||
poem = { version = "3.1.12", features = ["embed", "compression", "rustls"] }
|
|
||||||
poem-derive = "3.1.12"
|
|
||||||
poem-openapi = { version = "5.1.16", features = [
|
|
||||||
"openapi-explorer",
|
|
||||||
"rapidoc",
|
|
||||||
"scalar",
|
|
||||||
"redoc",
|
|
||||||
"swagger-ui",
|
|
||||||
"email",
|
|
||||||
] }
|
|
||||||
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.149"
|
||||||
tokio = { version = "1.49.0", features = ["full"] }
|
tokio = { version = "1.52.3", 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.23", features = ["env-filter", "json"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
snafu = "0.8.9"
|
snafu = "0.9.0"
|
||||||
reqwest = { version = "0.12.24", default-features = false, features = [
|
reqwest = { version = "0.12.24", default-features = false, features = [
|
||||||
"json",
|
"json",
|
||||||
"stream",
|
"stream",
|
||||||
@@ -57,69 +39,59 @@ reqwest = { version = "0.12.24", default-features = false, features = [
|
|||||||
] }
|
] }
|
||||||
tokio-socks = "0.5.2"
|
tokio-socks = "0.5.2"
|
||||||
http = "1.4.0"
|
http = "1.4.0"
|
||||||
regex = "1.12.2"
|
regex = "1.12.3"
|
||||||
email_address = "0.2.9"
|
email_address = "0.2.9"
|
||||||
futures = "0.3.31"
|
futures = "0.3.32"
|
||||||
utf7-imap = "0.3.2"
|
utf7-imap = "0.3.2"
|
||||||
imap-proto = "0.16.6"
|
mail-parser = { version = '0.11.3', features = ["serde"] }
|
||||||
mail-parser = { version = '0.11.1', features = ["serde"] }
|
|
||||||
# mail-send = "0.5.2"
|
# mail-send = "0.5.2"
|
||||||
tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||||
"ring",
|
"ring",
|
||||||
"tls12",
|
"tls12",
|
||||||
] }
|
] }
|
||||||
timeago = "0.5.0"
|
timeago = "0.6.0"
|
||||||
ahash = "0.8.12"
|
|
||||||
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
|
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
|
||||||
url = { version = "2.5.8", features = ["serde"] }
|
url = { version = "2.5.8", features = ["serde"] }
|
||||||
sysinfo = "0.37.2"
|
sysinfo = "0.39.1"
|
||||||
num_cpus = "1.17.0"
|
num_cpus = "1.17.0"
|
||||||
cacache = { version = "13.1.0", default-features = false, features = [
|
rand = "0.10.1"
|
||||||
"tokio-runtime",
|
|
||||||
"mmap",
|
|
||||||
] }
|
|
||||||
rand = "0.9.2"
|
|
||||||
encoding_rs = "0.8.35"
|
encoding_rs = "0.8.35"
|
||||||
async-imap = { version = "0.11.1", default-features = false, features = [
|
webpki-roots = "1.0.7"
|
||||||
"runtime-tokio",
|
rustls = { version = "0.23.40", default-features = false, features = ["ring"] }
|
||||||
"compress",
|
rustls-pki-types = "1.14.1"
|
||||||
] }
|
|
||||||
webpki-roots = "1.0.4"
|
|
||||||
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
|
|
||||||
rustls-pki-types = "1.13.2"
|
|
||||||
tokio-io-timeout = "1.2.1"
|
tokio-io-timeout = "1.2.1"
|
||||||
bb8 = "0.9.1"
|
semver = "1.0.28"
|
||||||
semver = "1.0.27"
|
|
||||||
governor = "0.10.4"
|
governor = "0.10.4"
|
||||||
lru = "0.16.2"
|
lru = "0.18.0"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
time = { version = "0.3.44", features = [
|
time = { version = "0.3.47", features = [
|
||||||
"formatting",
|
"formatting",
|
||||||
"parsing",
|
"parsing",
|
||||||
"local-offset",
|
"local-offset",
|
||||||
] }
|
] }
|
||||||
rust-embed = "8.9.0"
|
rust-embed = "8.11.0"
|
||||||
murmur3 = "0.5.2"
|
murmur3 = "0.5.2"
|
||||||
autoconfig = "0.4.0"
|
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
# Statically links OpenSSL by compiling from source, avoiding system library dependencies
|
|
||||||
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
|
|
||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
|
itoa = "1.0.18"
|
||||||
itoa = "1.0.17"
|
html2text = "0.17.1"
|
||||||
html2text = "0.16.5"
|
bytes = "1.11.1"
|
||||||
bytes = "1.11.0"
|
|
||||||
dialoguer = "0.12.0"
|
dialoguer = "0.12.0"
|
||||||
console = "0.16.2"
|
console = "0.16.3"
|
||||||
toml = "0.9.8"
|
mail-send = "0.6.0"
|
||||||
memmap2 = "0.9.9"
|
rcgen = "0.14.8"
|
||||||
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
|
rustls-pemfile = "2.2.0"
|
||||||
compressed-rtf = "1.0.0"
|
blake3 = "1.8.5"
|
||||||
codepage-strings = "1.0.2"
|
uuid = { version = "1.23.1", features = ["v4", "serde"] }
|
||||||
mail-send = "0.5.2"
|
fjall = { version = "3.1.4", features = ["lz4", "metrics", "bytes_1"] }
|
||||||
[dev-dependencies]
|
tracing-log = "0.2.0"
|
||||||
#bincode = "1.3.3"
|
tokio-util = "0.7.18"
|
||||||
#secret-lib = "1.0.0"
|
indicatif = "0.18.4"
|
||||||
tempfile = "3.24.0"
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
opt-level = 3
|
||||||
|
codegen-units = 1
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
base_url = "http://localhost:15630"
|
base_url = "http://localhost:15630"
|
||||||
api_token = "UvkGJO0Mn1tO6igGZwQnTtI2"
|
api_token = "WuqNC0g8yNle7CVnxcvjUwjN"
|
||||||
|
|||||||
21
crates/admin/Cargo.toml
Normal file
21
crates/admin/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "bichon-admin"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bichon-core = { path = "../core" }
|
||||||
|
tokio.workspace = true
|
||||||
|
dialoguer.workspace = true
|
||||||
|
console.workspace = true
|
||||||
|
indicatif.workspace = true
|
||||||
|
native_db = "0.8.2"
|
||||||
|
native_model = "0.4.20"
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
itertools.workspace = true
|
||||||
|
snafu.workspace = true
|
||||||
|
memdb.workspace = true
|
||||||
|
mimalloc = "0.1.50"
|
||||||
64
crates/admin/src/main.rs
Normal file
64
crates/admin/src/main.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use console::style;
|
||||||
|
use dialoguer::{theme::ColorfulTheme, Select};
|
||||||
|
use mimalloc::MiMalloc;
|
||||||
|
|
||||||
|
use crate::{migrate::handle_migration, reset::handle_reset_password};
|
||||||
|
|
||||||
|
pub mod meta;
|
||||||
|
pub mod migrate;
|
||||||
|
pub mod reset;
|
||||||
|
|
||||||
|
#[global_allocator]
|
||||||
|
static GLOBAL: MiMalloc = MiMalloc;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
run_interactive();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn run_interactive() {
|
||||||
|
let theme = ColorfulTheme::default();
|
||||||
|
println!(
|
||||||
|
"\n{}\n",
|
||||||
|
style("BICHON ADMINISTRATIVE TOOL").bold().bright().cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
let main_options = vec![
|
||||||
|
"Reset Admin Password",
|
||||||
|
"Migrate Legacy v0.3.7 Storage to v1.0.x",
|
||||||
|
"Exit",
|
||||||
|
];
|
||||||
|
|
||||||
|
let selection = Select::with_theme(&theme)
|
||||||
|
.with_prompt("Select an operation")
|
||||||
|
.default(0)
|
||||||
|
.items(&main_options)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match selection {
|
||||||
|
0 => handle_reset_password(&theme),
|
||||||
|
1 => handle_migration(&theme),
|
||||||
|
_ => {
|
||||||
|
println!("{}", style("Exiting...").dim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
910
crates/admin/src/meta.rs
Normal file
910
crates/admin/src/meta.rs
Normal file
@@ -0,0 +1,910 @@
|
|||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{Arc, LazyLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
use bichon_core::{
|
||||||
|
account::{
|
||||||
|
entity::ImapConfig,
|
||||||
|
migration::{AccountModel, AccountType},
|
||||||
|
since::{DateSince, RelativeDate},
|
||||||
|
},
|
||||||
|
autoconfig::entity::MailServerConfig,
|
||||||
|
cache::imap::mailbox::Attribute,
|
||||||
|
database::batch_insert_impl,
|
||||||
|
error::{code::ErrorCode, BichonError, BichonResult},
|
||||||
|
raise_error,
|
||||||
|
token::TokenType,
|
||||||
|
users::{acl::AccessControl, role::RoleType},
|
||||||
|
};
|
||||||
|
use console::style;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use memdb::{Durability, MemDb};
|
||||||
|
use native_db::*;
|
||||||
|
use native_model::{native_model, Model};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[native_model(id = 3, version = 1)]
|
||||||
|
#[native_db]
|
||||||
|
pub struct CachedMailSettings {
|
||||||
|
#[primary_key]
|
||||||
|
pub domain: String,
|
||||||
|
pub config: MailServerConfig,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CachedMailSettings> for bichon_core::autoconfig::CachedMailSettings {
|
||||||
|
fn from(value: CachedMailSettings) -> Self {
|
||||||
|
Self {
|
||||||
|
domain: value.domain,
|
||||||
|
config: value.config,
|
||||||
|
created_at: value.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[native_model(id = 4, version = 1)]
|
||||||
|
#[native_db(primary_key(pk -> String))]
|
||||||
|
pub struct AccountV1 {
|
||||||
|
#[secondary_key(unique)]
|
||||||
|
pub id: u64,
|
||||||
|
pub imap: Option<ImapConfig>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub email: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub capabilities: Option<Vec<String>>,
|
||||||
|
pub date_since: Option<DateSince>,
|
||||||
|
pub folder_limit: Option<u32>,
|
||||||
|
pub sync_folders: Option<Vec<String>>,
|
||||||
|
pub account_type: AccountType,
|
||||||
|
pub sync_interval_min: Option<i64>,
|
||||||
|
pub known_folders: Option<BTreeSet<String>>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub use_proxy: Option<u64>,
|
||||||
|
}
|
||||||
|
impl AccountV1 {
|
||||||
|
fn pk(&self) -> String {
|
||||||
|
format!("{}_{}", self.created_at, self.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[native_model(id = 4, version = 2, from = AccountV1)]
|
||||||
|
#[native_db(primary_key(pk -> String))]
|
||||||
|
pub struct AccountV2 {
|
||||||
|
#[secondary_key(unique)]
|
||||||
|
pub id: u64,
|
||||||
|
pub imap: Option<ImapConfig>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub email: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub capabilities: Option<Vec<String>>,
|
||||||
|
pub date_since: Option<DateSince>,
|
||||||
|
pub folder_limit: Option<u32>,
|
||||||
|
pub sync_folders: Option<Vec<String>>,
|
||||||
|
pub account_type: AccountType,
|
||||||
|
pub sync_interval_min: Option<i64>,
|
||||||
|
pub known_folders: Option<BTreeSet<String>>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub use_proxy: Option<u64>,
|
||||||
|
pub use_dangerous: bool,
|
||||||
|
pub pgp_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountV2 {
|
||||||
|
fn pk(&self) -> String {
|
||||||
|
format!("{}_{}", self.created_at, self.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[native_model(id = 4, version = 3, from = AccountV2)]
|
||||||
|
#[native_db(primary_key(pk -> String))]
|
||||||
|
pub struct AccountV3 {
|
||||||
|
#[secondary_key(unique)]
|
||||||
|
pub id: u64,
|
||||||
|
pub imap: Option<ImapConfig>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub email: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub capabilities: Option<Vec<String>>,
|
||||||
|
pub date_since: Option<DateSince>,
|
||||||
|
pub date_before: Option<RelativeDate>,
|
||||||
|
pub folder_limit: Option<u32>,
|
||||||
|
pub sync_folders: Option<Vec<String>>,
|
||||||
|
pub account_type: AccountType,
|
||||||
|
pub sync_interval_min: Option<i64>,
|
||||||
|
pub sync_batch_size: Option<u32>,
|
||||||
|
pub known_folders: Option<BTreeSet<String>>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub created_by: u64, //user id
|
||||||
|
pub use_proxy: Option<u64>,
|
||||||
|
pub use_dangerous: bool,
|
||||||
|
pub pgp_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountV3 {
|
||||||
|
fn pk(&self) -> String {
|
||||||
|
format!("{}_{}", self.created_at, self.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AccountV1> for AccountV2 {
|
||||||
|
fn from(value: AccountV1) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
imap: value.imap,
|
||||||
|
enabled: value.enabled,
|
||||||
|
email: value.email,
|
||||||
|
name: value.name,
|
||||||
|
capabilities: value.capabilities,
|
||||||
|
date_since: value.date_since,
|
||||||
|
folder_limit: value.folder_limit,
|
||||||
|
sync_folders: value.sync_folders,
|
||||||
|
account_type: value.account_type,
|
||||||
|
sync_interval_min: value.sync_interval_min,
|
||||||
|
known_folders: value.known_folders,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
use_proxy: value.use_proxy,
|
||||||
|
use_dangerous: false,
|
||||||
|
pgp_key: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AccountV2> for AccountV1 {
|
||||||
|
fn from(value: AccountV2) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
imap: value.imap,
|
||||||
|
enabled: value.enabled,
|
||||||
|
email: value.email,
|
||||||
|
name: value.name,
|
||||||
|
capabilities: value.capabilities,
|
||||||
|
date_since: value.date_since,
|
||||||
|
folder_limit: value.folder_limit,
|
||||||
|
sync_folders: value.sync_folders,
|
||||||
|
account_type: value.account_type,
|
||||||
|
sync_interval_min: value.sync_interval_min,
|
||||||
|
known_folders: value.known_folders,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
use_proxy: value.use_proxy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AccountV3> for AccountV2 {
|
||||||
|
fn from(value: AccountV3) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
imap: value.imap,
|
||||||
|
enabled: value.enabled,
|
||||||
|
email: value.email,
|
||||||
|
name: value.name,
|
||||||
|
capabilities: value.capabilities,
|
||||||
|
date_since: value.date_since,
|
||||||
|
folder_limit: value.folder_limit,
|
||||||
|
sync_folders: value.sync_folders,
|
||||||
|
account_type: value.account_type,
|
||||||
|
sync_interval_min: value.sync_interval_min,
|
||||||
|
known_folders: value.known_folders,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
use_proxy: value.use_proxy,
|
||||||
|
use_dangerous: value.use_dangerous,
|
||||||
|
pgp_key: value.pgp_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AccountV2> for AccountV3 {
|
||||||
|
fn from(value: AccountV2) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
imap: value.imap,
|
||||||
|
enabled: value.enabled,
|
||||||
|
email: value.email,
|
||||||
|
name: value.name,
|
||||||
|
capabilities: value.capabilities,
|
||||||
|
date_since: value.date_since,
|
||||||
|
folder_limit: value.folder_limit,
|
||||||
|
sync_folders: value.sync_folders,
|
||||||
|
account_type: value.account_type,
|
||||||
|
sync_interval_min: value.sync_interval_min,
|
||||||
|
known_folders: value.known_folders,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
created_by: DEFAULT_ADMIN_USER_ID,
|
||||||
|
use_proxy: value.use_proxy,
|
||||||
|
use_dangerous: value.use_dangerous,
|
||||||
|
pgp_key: value.pgp_key,
|
||||||
|
sync_batch_size: None,
|
||||||
|
date_before: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AccountV3> for AccountModel {
|
||||||
|
fn from(value: AccountV3) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
imap: value.imap,
|
||||||
|
enabled: value.enabled,
|
||||||
|
email: value.email,
|
||||||
|
account_name: None,
|
||||||
|
login_name: value.name,
|
||||||
|
capabilities: value.capabilities,
|
||||||
|
date_since: value.date_since,
|
||||||
|
date_before: value.date_before,
|
||||||
|
folder_limit: value.folder_limit,
|
||||||
|
download_folders: value.sync_folders,
|
||||||
|
account_type: value.account_type,
|
||||||
|
download_interval_min: value.sync_interval_min,
|
||||||
|
download_batch_size: value.sync_batch_size,
|
||||||
|
known_folders: value.known_folders,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
created_by: value.created_by,
|
||||||
|
use_proxy: value.use_proxy,
|
||||||
|
use_dangerous: value.use_dangerous,
|
||||||
|
pgp_key: value.pgp_key,
|
||||||
|
imap_quota_window: None,
|
||||||
|
imap_quota_bytes: None,
|
||||||
|
auto_download_new_mailboxes: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[native_model(id = 5, version = 1)]
|
||||||
|
#[native_db(primary_key(pk -> String))]
|
||||||
|
pub struct OAuth2 {
|
||||||
|
/// A unique identifier for the OAuth2 configuration.
|
||||||
|
#[secondary_key(unique)]
|
||||||
|
pub id: u64,
|
||||||
|
/// A description of what this configuration is used for.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// The client ID used for authenticating the application with the OAuth2 provider.
|
||||||
|
pub client_id: String,
|
||||||
|
/// The client secret used in conjunction with the client ID.
|
||||||
|
///
|
||||||
|
/// Users should provide a plaintext secret.
|
||||||
|
/// The server will encrypt it using AES-256-GCM and securely store it.
|
||||||
|
/// The plaintext secret is never stored, so users must ensure it is valid for OAuth2 authentication.
|
||||||
|
pub client_secret: String,
|
||||||
|
/// The URL to redirect users to for OAuth2 authorization.
|
||||||
|
pub auth_url: String,
|
||||||
|
/// The URL to exchange authorization codes for access tokens.
|
||||||
|
pub token_url: String,
|
||||||
|
/// The URI where the OAuth2 provider will redirect to after authorization.
|
||||||
|
pub redirect_uri: String,
|
||||||
|
/// The scopes of access that are being requested (e.g., email, profile).
|
||||||
|
pub scopes: Option<Vec<String>>,
|
||||||
|
/// Any additional parameters to include in the OAuth2 requests (e.g., access_type, prompt).
|
||||||
|
pub extra_params: Option<BTreeMap<String, String>>,
|
||||||
|
/// Indicates whether this configuration is enabled or disabled.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// route OAuth through proxy (when direct access is blocked)
|
||||||
|
pub use_proxy: Option<u64>,
|
||||||
|
/// The timestamp when the configuration was created, in milliseconds since the Unix epoch.
|
||||||
|
pub created_at: i64,
|
||||||
|
/// The timestamp when the configuration was last updated, in milliseconds since the Unix epoch.
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuth2 {
|
||||||
|
fn pk(&self) -> String {
|
||||||
|
format!("{}_{}", &self.created_at, &self.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<OAuth2> for bichon_core::oauth2::entity::OAuth2 {
|
||||||
|
fn from(value: OAuth2) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
description: value.description,
|
||||||
|
client_id: value.client_id,
|
||||||
|
client_secret: value.client_secret,
|
||||||
|
auth_url: value.auth_url,
|
||||||
|
token_url: value.token_url,
|
||||||
|
redirect_uri: value.redirect_uri,
|
||||||
|
scopes: value.scopes,
|
||||||
|
extra_params: value.extra_params,
|
||||||
|
enabled: value.enabled,
|
||||||
|
use_proxy: value.use_proxy,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[native_model(id = 6, version = 1)]
|
||||||
|
#[native_db]
|
||||||
|
pub struct OAuth2PendingEntity {
|
||||||
|
/// Unique identifier for the OAuth2 request record
|
||||||
|
pub oauth2_id: u64,
|
||||||
|
|
||||||
|
pub account_id: u64,
|
||||||
|
/// CSRF protection state parameter used to verify the integrity of the authorization request
|
||||||
|
#[primary_key]
|
||||||
|
pub state: String,
|
||||||
|
|
||||||
|
/// PKCE code verifier used in the authorization code exchange process to ensure security
|
||||||
|
pub code_verifier: String,
|
||||||
|
|
||||||
|
/// Timestamp when the OAuth2 request was created, used to determine request expiration
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<OAuth2PendingEntity> for bichon_core::oauth2::pending::OAuth2PendingEntity {
|
||||||
|
fn from(value: OAuth2PendingEntity) -> Self {
|
||||||
|
Self {
|
||||||
|
oauth2_id: value.oauth2_id,
|
||||||
|
account_id: value.account_id,
|
||||||
|
state: value.state,
|
||||||
|
code_verifier: value.code_verifier,
|
||||||
|
created_at: value.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[native_model(id = 7, version = 1)]
|
||||||
|
#[native_db]
|
||||||
|
pub struct OAuth2AccessToken {
|
||||||
|
/// The ID of the account associated with this access token.
|
||||||
|
#[primary_key]
|
||||||
|
pub account_id: u64,
|
||||||
|
/// The id of the OAuth2 configuration associated with this access token.
|
||||||
|
#[secondary_key]
|
||||||
|
pub oauth2_id: u64,
|
||||||
|
/// The OAuth2 access token used to authenticate requests to the provider.
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
/// The OAuth2 refresh token used to obtain new access tokens.
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
/// The timestamp when the token record was created, in milliseconds since the Unix epoch.
|
||||||
|
pub created_at: i64,
|
||||||
|
/// The timestamp when the token record was last updated, in milliseconds since the Unix epoch.
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<OAuth2AccessToken> for bichon_core::oauth2::token::OAuth2AccessToken {
|
||||||
|
fn from(value: OAuth2AccessToken) -> Self {
|
||||||
|
Self {
|
||||||
|
account_id: value.account_id,
|
||||||
|
oauth2_id: value.oauth2_id,
|
||||||
|
access_token: value.access_token,
|
||||||
|
refresh_token: value.refresh_token,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[native_model(id = 8, version = 1)]
|
||||||
|
#[native_db]
|
||||||
|
pub struct Proxy {
|
||||||
|
/// The unique identifier for this proxy configuration.
|
||||||
|
#[primary_key]
|
||||||
|
pub id: u64,
|
||||||
|
|
||||||
|
/// The proxy URL (e.g., socks5://127.0.0.1:1080) used to route network requests.
|
||||||
|
pub url: String,
|
||||||
|
|
||||||
|
/// The creation timestamp of this record, represented as milliseconds since the Unix epoch.
|
||||||
|
pub created_at: i64,
|
||||||
|
|
||||||
|
/// The last update timestamp of this record, represented as milliseconds since the Unix epoch.
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Proxy> for bichon_core::settings::proxy::Proxy {
|
||||||
|
fn from(value: Proxy) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
url: value.url,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[native_model(id = 9, version = 1)]
|
||||||
|
#[native_db]
|
||||||
|
pub struct UserRole {
|
||||||
|
#[primary_key]
|
||||||
|
pub id: u64,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub permissions: BTreeSet<String>,
|
||||||
|
pub is_builtin: bool,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub role_type: RoleType,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserRole> for bichon_core::users::role::UserRole {
|
||||||
|
fn from(value: UserRole) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
name: value.name,
|
||||||
|
description: value.description,
|
||||||
|
permissions: value.permissions,
|
||||||
|
is_builtin: value.is_builtin,
|
||||||
|
created_at: value.created_at,
|
||||||
|
role_type: value.role_type,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[native_model(id = 10, version = 1)]
|
||||||
|
#[native_db]
|
||||||
|
pub struct BichonUser {
|
||||||
|
#[primary_key]
|
||||||
|
pub id: u64,
|
||||||
|
#[secondary_key(unique)]
|
||||||
|
pub username: String,
|
||||||
|
#[secondary_key(unique)]
|
||||||
|
pub email: String,
|
||||||
|
|
||||||
|
pub password: Option<String>,
|
||||||
|
|
||||||
|
/// Scoped Access: Defines per-account permissions.
|
||||||
|
/// Example:
|
||||||
|
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
|
||||||
|
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
|
||||||
|
pub account_access_map: BTreeMap<u64, u64>,
|
||||||
|
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
/// System Roles: Permissions that apply to the whole system
|
||||||
|
/// (e.g., system settings, creating new users).
|
||||||
|
pub global_roles: Vec<u64>,
|
||||||
|
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
/// Optional access control settings
|
||||||
|
pub acl: Option<AccessControl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[native_model(id = 10, version = 2, from = BichonUser)]
|
||||||
|
#[native_db]
|
||||||
|
pub struct BichonUserV2 {
|
||||||
|
#[primary_key]
|
||||||
|
pub id: u64,
|
||||||
|
#[secondary_key(unique)]
|
||||||
|
pub username: String,
|
||||||
|
#[secondary_key(unique)]
|
||||||
|
pub email: String,
|
||||||
|
|
||||||
|
pub password: Option<String>,
|
||||||
|
|
||||||
|
/// Scoped Access: Defines per-account permissions.
|
||||||
|
/// Example:
|
||||||
|
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
|
||||||
|
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
|
||||||
|
pub account_access_map: BTreeMap<u64, u64>,
|
||||||
|
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
/// System Roles: Permissions that apply to the whole system
|
||||||
|
/// (e.g., system settings, creating new users).
|
||||||
|
pub global_roles: Vec<u64>,
|
||||||
|
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
/// Optional access control settings
|
||||||
|
pub acl: Option<AccessControl>,
|
||||||
|
|
||||||
|
pub theme: Option<String>,
|
||||||
|
pub language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BichonUserV2> for BichonUser {
|
||||||
|
fn from(value: BichonUserV2) -> Self {
|
||||||
|
BichonUser {
|
||||||
|
id: value.id,
|
||||||
|
username: value.username,
|
||||||
|
email: value.email,
|
||||||
|
password: value.password,
|
||||||
|
account_access_map: value.account_access_map,
|
||||||
|
description: value.description,
|
||||||
|
global_roles: value.global_roles,
|
||||||
|
avatar: value.avatar,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
acl: value.acl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BichonUser> for BichonUserV2 {
|
||||||
|
fn from(value: BichonUser) -> Self {
|
||||||
|
BichonUserV2 {
|
||||||
|
id: value.id,
|
||||||
|
username: value.username,
|
||||||
|
email: value.email,
|
||||||
|
password: value.password,
|
||||||
|
account_access_map: value.account_access_map,
|
||||||
|
description: value.description,
|
||||||
|
global_roles: value.global_roles,
|
||||||
|
avatar: value.avatar,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
acl: value.acl,
|
||||||
|
theme: None,
|
||||||
|
language: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BichonUserV2> for bichon_core::users::BichonUserV2 {
|
||||||
|
fn from(value: BichonUserV2) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
username: value.username,
|
||||||
|
email: value.email,
|
||||||
|
password: value.password,
|
||||||
|
account_access_map: value.account_access_map,
|
||||||
|
description: value.description,
|
||||||
|
global_roles: value.global_roles,
|
||||||
|
avatar: value.avatar,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
acl: value.acl,
|
||||||
|
theme: value.theme,
|
||||||
|
language: value.language,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[native_model(id = 11, version = 1)]
|
||||||
|
#[native_db]
|
||||||
|
pub struct AccessTokenModel {
|
||||||
|
/// The ID of the user who owns this token
|
||||||
|
#[secondary_key]
|
||||||
|
pub user_id: u64,
|
||||||
|
/// The unique token string used for authentication
|
||||||
|
#[primary_key]
|
||||||
|
pub token: String,
|
||||||
|
/// An optional name of the token.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Token type: WebUI or API
|
||||||
|
pub token_type: TokenType,
|
||||||
|
/// The timestamp (in milliseconds since epoch) when the token was created.
|
||||||
|
pub created_at: i64,
|
||||||
|
/// The timestamp (in milliseconds since epoch) when the token was last updated.
|
||||||
|
pub updated_at: i64,
|
||||||
|
/// The timestamp (in milliseconds since epoch) when the token expires.
|
||||||
|
/// None means the token does not expire (this applies only to API tokens).
|
||||||
|
pub expire_at: Option<i64>,
|
||||||
|
/// The timestamp (in milliseconds since epoch) when the token was last used.
|
||||||
|
pub last_access_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AccessTokenModel> for bichon_core::token::AccessTokenModel {
|
||||||
|
fn from(value: AccessTokenModel) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id: value.user_id,
|
||||||
|
token: value.token,
|
||||||
|
name: value.name,
|
||||||
|
token_type: value.token_type,
|
||||||
|
created_at: value.created_at,
|
||||||
|
updated_at: value.updated_at,
|
||||||
|
expire_at: value.expire_at,
|
||||||
|
last_access_at: value.last_access_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[native_model(id = 1, version = 1)]
|
||||||
|
#[native_db]
|
||||||
|
pub struct MailBox {
|
||||||
|
/// The unique identifier for the mailbox
|
||||||
|
#[primary_key]
|
||||||
|
pub id: u64,
|
||||||
|
/// The ID of the account associated with the mailbox
|
||||||
|
#[secondary_key]
|
||||||
|
pub account_id: u64,
|
||||||
|
/// The unique, decoded, human-readable name of the mailbox (e.g., "INBOX", "Sent Items").
|
||||||
|
/// This is the decoded name as presented to users, derived from the IMAP server's mailbox name
|
||||||
|
/// (e.g., after decoding UTF-7 or other encodings per RFC 3501).
|
||||||
|
pub name: String,
|
||||||
|
/// Optional delimiter used to separate mailbox names in a hierarchy (e.g., "/" or ".").
|
||||||
|
/// Used in IMAP to structure nested mailboxes (e.g., "INBOX/Archive").
|
||||||
|
pub delimiter: Option<String>,
|
||||||
|
/// List of attributes associated with the mailbox (e.g., `\NoSelect`, `\Deleted`).
|
||||||
|
/// These indicate special properties, such as whether the mailbox can hold messages.
|
||||||
|
pub attributes: Vec<Attribute>,
|
||||||
|
/// The number of messages that currently exist in the mailbox.
|
||||||
|
pub exists: u32,
|
||||||
|
/// Optional number of unseen messages in the mailbox (i.e., messages without the `\Seen` flag).
|
||||||
|
pub unseen: Option<u32>,
|
||||||
|
/// The next unique identifier (UID) that will be assigned to a new message in the mailbox.
|
||||||
|
/// If `None`, the IMAP server has not provided this information.
|
||||||
|
pub uid_next: Option<u32>,
|
||||||
|
/// The validity identifier for UIDs in this mailbox, used to ensure UID consistency across sessions.
|
||||||
|
/// If `None`, the IMAP server has not provided this information.
|
||||||
|
pub uid_validity: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MailBox> for bichon_core::cache::imap::mailbox::MailBox {
|
||||||
|
fn from(value: MailBox) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
account_id: value.account_id,
|
||||||
|
name: value.name,
|
||||||
|
delimiter: value.delimiter,
|
||||||
|
attributes: value.attributes,
|
||||||
|
exists: value.exists,
|
||||||
|
unseen: value.unseen,
|
||||||
|
uid_next: value.uid_next,
|
||||||
|
uid_validity: value.uid_validity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static META_MODELS: LazyLock<Models> = LazyLock::new(|| {
|
||||||
|
let mut adapter = ModelsAdapter::new();
|
||||||
|
adapter.register_metadata_models();
|
||||||
|
adapter.models
|
||||||
|
});
|
||||||
|
|
||||||
|
pub static MAILBOX_MODELS: LazyLock<Models> = LazyLock::new(|| {
|
||||||
|
let mut adapter = ModelsAdapter::new();
|
||||||
|
adapter.register_model::<MailBox>();
|
||||||
|
adapter.models
|
||||||
|
});
|
||||||
|
|
||||||
|
pub struct ModelsAdapter {
|
||||||
|
pub models: Models,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelsAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
ModelsAdapter {
|
||||||
|
models: Models::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_model<T: ToInput>(&mut self) {
|
||||||
|
self.models.define::<T>().expect("failed to define model ");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_metadata_models(&mut self) {
|
||||||
|
self.register_model::<CachedMailSettings>();
|
||||||
|
self.register_model::<AccountV1>();
|
||||||
|
self.register_model::<AccountV2>();
|
||||||
|
self.register_model::<AccountV3>();
|
||||||
|
self.register_model::<OAuth2>();
|
||||||
|
self.register_model::<OAuth2PendingEntity>();
|
||||||
|
self.register_model::<OAuth2AccessToken>();
|
||||||
|
self.register_model::<Proxy>();
|
||||||
|
self.register_model::<UserRole>();
|
||||||
|
self.register_model::<BichonUser>();
|
||||||
|
self.register_model::<BichonUserV2>();
|
||||||
|
self.register_model::<AccessTokenModel>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_meta_database(root_path: &PathBuf) -> BichonResult<Arc<Database<'static>>> {
|
||||||
|
let mut database = Builder::new()
|
||||||
|
.set_cache_size(134217728)
|
||||||
|
.create(&META_MODELS, root_path.join("meta.db"))
|
||||||
|
.map_err(handle_database_error)?;
|
||||||
|
|
||||||
|
let rw = database
|
||||||
|
.rw_transaction()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
rw.migrate::<AccountV3>()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
rw.migrate::<BichonUserV2>()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
rw.commit()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
database
|
||||||
|
.compact()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
Ok(Arc::new(database))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_evenlope_database(root_path: &PathBuf) -> BichonResult<Arc<Database<'static>>> {
|
||||||
|
let mut database = Builder::new()
|
||||||
|
.set_cache_size(1073741824)
|
||||||
|
.create(&MAILBOX_MODELS, root_path.join("mailbox.db"))
|
||||||
|
.map_err(handle_database_error)?;
|
||||||
|
|
||||||
|
let rw = database
|
||||||
|
.rw_transaction()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
rw.commit()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
database
|
||||||
|
.compact()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
Ok(Arc::new(database))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_database_error(error: native_db::db_type::Error) -> BichonError {
|
||||||
|
raise_error!(
|
||||||
|
format!("Failed to create database: {:?}", error),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_all_impl<T: ToInput + Clone + Send + 'static>(
|
||||||
|
database: &Arc<Database<'static>>,
|
||||||
|
) -> BichonResult<Vec<T>> {
|
||||||
|
let r_transaction = database
|
||||||
|
.r_transaction()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
let entities: Vec<T> = r_transaction
|
||||||
|
.scan()
|
||||||
|
.primary()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||||
|
.all()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||||
|
.try_collect()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
Ok(entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrate_metadata(root_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Pre-flight: verify old metadata databases exist
|
||||||
|
let meta_db_path = root_path.join("meta.db");
|
||||||
|
if !meta_db_path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Legacy metadata database not found at '{}'. \
|
||||||
|
Make sure the root directory points to a valid v0.3.7 installation.",
|
||||||
|
meta_db_path.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let mailbox_db_path = root_path.join("mailbox.db");
|
||||||
|
if !mailbox_db_path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Legacy mailbox database not found at '{}'. \
|
||||||
|
Make sure the root directory points to a valid v0.3.7 installation.",
|
||||||
|
mailbox_db_path.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize legacy database connections
|
||||||
|
let meta_db = init_meta_database(root_path)
|
||||||
|
.map_err(|e| format!("Failed to initialize legacy metadata database: {}", e))?;
|
||||||
|
let envelope_db = init_evenlope_database(root_path)
|
||||||
|
.map_err(|e| format!("Failed to initialize legacy envelope database: {}", e))?;
|
||||||
|
|
||||||
|
// Prepare new database directory
|
||||||
|
let db_path = root_path.join("memdb");
|
||||||
|
if !db_path.exists() {
|
||||||
|
std::fs::create_dir_all(&db_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open new database (disable full durability for faster bulk writes)
|
||||||
|
let db = MemDb::open_with(&db_path, Durability::Off)
|
||||||
|
.map_err(|e| format!("Failed to open new memdb database: {}", e))?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style("Step 1: Migrating Metadata Entities...")
|
||||||
|
.bold()
|
||||||
|
.cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migration helper macro to reduce boilerplate
|
||||||
|
macro_rules! migrate_collection {
|
||||||
|
($name:expr, $old_type:ty, $new_type:ty, $source_db:expr) => {
|
||||||
|
print!(" > {:<25} ", $name);
|
||||||
|
let items = list_all_impl::<$old_type>($source_db)?;
|
||||||
|
let count = items.len();
|
||||||
|
let converted: Vec<$new_type> = items.into_iter().map(|a| a.into()).collect();
|
||||||
|
batch_insert_impl(&db, converted)?;
|
||||||
|
println!("{} ({} items)", style("done").green(), count);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Migrate each entity type ---
|
||||||
|
|
||||||
|
migrate_collection!(
|
||||||
|
"Mail Settings",
|
||||||
|
CachedMailSettings,
|
||||||
|
bichon_core::autoconfig::CachedMailSettings,
|
||||||
|
&meta_db
|
||||||
|
);
|
||||||
|
|
||||||
|
migrate_collection!("Accounts", AccountV3, AccountModel, &meta_db);
|
||||||
|
|
||||||
|
migrate_collection!(
|
||||||
|
"OAuth2 Entities",
|
||||||
|
OAuth2,
|
||||||
|
bichon_core::oauth2::entity::OAuth2,
|
||||||
|
&meta_db
|
||||||
|
);
|
||||||
|
|
||||||
|
migrate_collection!(
|
||||||
|
"OAuth2 Pending",
|
||||||
|
OAuth2PendingEntity,
|
||||||
|
bichon_core::oauth2::pending::OAuth2PendingEntity,
|
||||||
|
&meta_db
|
||||||
|
);
|
||||||
|
|
||||||
|
migrate_collection!(
|
||||||
|
"OAuth2 Access Tokens",
|
||||||
|
OAuth2AccessToken,
|
||||||
|
bichon_core::oauth2::token::OAuth2AccessToken,
|
||||||
|
&meta_db
|
||||||
|
);
|
||||||
|
|
||||||
|
migrate_collection!(
|
||||||
|
"Proxy Settings",
|
||||||
|
Proxy,
|
||||||
|
bichon_core::settings::proxy::Proxy,
|
||||||
|
&meta_db
|
||||||
|
);
|
||||||
|
|
||||||
|
migrate_collection!(
|
||||||
|
"User Roles",
|
||||||
|
UserRole,
|
||||||
|
bichon_core::users::role::UserRole,
|
||||||
|
&meta_db
|
||||||
|
);
|
||||||
|
|
||||||
|
migrate_collection!(
|
||||||
|
"Users",
|
||||||
|
BichonUserV2,
|
||||||
|
bichon_core::users::BichonUserV2,
|
||||||
|
&meta_db
|
||||||
|
);
|
||||||
|
|
||||||
|
migrate_collection!(
|
||||||
|
"Access Tokens",
|
||||||
|
AccessTokenModel,
|
||||||
|
bichon_core::token::AccessTokenModel,
|
||||||
|
&meta_db
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mailboxes (from envelope_db)
|
||||||
|
migrate_collection!(
|
||||||
|
"Mailboxes",
|
||||||
|
MailBox,
|
||||||
|
bichon_core::cache::imap::mailbox::MailBox,
|
||||||
|
&envelope_db
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist and finish
|
||||||
|
db.snapshot()
|
||||||
|
.map_err(|e| format!("Snapshot save failed: {}", e))?;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style("Metadata migration completed successfully.")
|
||||||
|
.green()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
436
crates/admin/src/migrate.rs
Normal file
436
crates/admin/src/migrate.rs
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use bichon_core::migrate::{
|
||||||
|
count_eml_segments, do_migrate_segment, is_tantivy_index_dir,
|
||||||
|
store::{LegacyDirs, NewDirs},
|
||||||
|
};
|
||||||
|
use console::style;
|
||||||
|
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
|
||||||
|
pub fn handle_migration(theme: &ColorfulTheme) {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
style("MIGRATION: Bichon v0.3.7 Storage Architecture → v1.0.x")
|
||||||
|
.bold()
|
||||||
|
.yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style(
|
||||||
|
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
|
||||||
|
architecture to the new v1.0.x \
|
||||||
|
separated index and Fjall-backed storage format."
|
||||||
|
)
|
||||||
|
.dim()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style(
|
||||||
|
"Legacy v0.3.7 architecture:\n\
|
||||||
|
• envelope metadata stored in Tantivy\n\
|
||||||
|
• message data stored in Tantivy\n\n\
|
||||||
|
New v1.0.x architecture:\n\
|
||||||
|
• mail indexes stored in Tantivy\n\
|
||||||
|
• attachment indexes stored in Tantivy\n\
|
||||||
|
• raw message data stored in Fjall\n\
|
||||||
|
• attachment blobs stored in Fjall"
|
||||||
|
)
|
||||||
|
.dim()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{} {}",
|
||||||
|
style("IMPORTANT:").yellow().bold(),
|
||||||
|
style(
|
||||||
|
"The paths below must exactly match what your old bichon server was configured with."
|
||||||
|
)
|
||||||
|
.yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- bichon-root-dir ---
|
||||||
|
let root_dir_str: String = Input::with_theme(theme)
|
||||||
|
.with_prompt("Enter --bichon-root-dir (same value used by the old server)")
|
||||||
|
.validate_with(|input: &String| -> Result<(), &str> {
|
||||||
|
let path = Path::new(input);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("Path must be absolute.");
|
||||||
|
}
|
||||||
|
if !path.exists() {
|
||||||
|
return Err("Directory does not exist.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let root_path = PathBuf::from(&root_dir_str);
|
||||||
|
|
||||||
|
// --- bichon-index-dir ---
|
||||||
|
let default_index = root_path.join("envelope");
|
||||||
|
let default_new_index = root_path.join("bichon-indices");
|
||||||
|
let index_dir_str: String = Input::with_theme(theme)
|
||||||
|
.with_prompt(format!(
|
||||||
|
"Enter --bichon-index-dir (leave blank to use default: {})",
|
||||||
|
style(default_index.display()).cyan()
|
||||||
|
))
|
||||||
|
.allow_empty(true)
|
||||||
|
.validate_with(|input: &String| -> Result<(), &str> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = Path::new(input);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("Path must be absolute.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Err("Directory does not exist.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let index_path = if index_dir_str.is_empty() {
|
||||||
|
default_index
|
||||||
|
} else {
|
||||||
|
PathBuf::from(&index_dir_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_index_path = if index_dir_str.is_empty() {
|
||||||
|
default_new_index
|
||||||
|
} else {
|
||||||
|
PathBuf::from(&index_dir_str).join("bichon-indices")
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- bichon-data-dir ---
|
||||||
|
let default_data = root_path.join("eml");
|
||||||
|
let default_new_data = root_path.join("bichon-storage");
|
||||||
|
let data_dir_str: String = Input::with_theme(theme)
|
||||||
|
.with_prompt(format!(
|
||||||
|
"Enter --bichon-data-dir (leave blank to use default: {})",
|
||||||
|
style(default_data.display()).cyan()
|
||||||
|
))
|
||||||
|
.allow_empty(true)
|
||||||
|
.validate_with(|input: &String| -> Result<(), &str> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = Path::new(input);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("Path must be absolute.");
|
||||||
|
}
|
||||||
|
if !path.exists() {
|
||||||
|
return Err("Directory does not exist.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let data_path = if data_dir_str.is_empty() {
|
||||||
|
default_data
|
||||||
|
} else {
|
||||||
|
PathBuf::from(&data_dir_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_data_path = if data_dir_str.is_empty() {
|
||||||
|
default_new_data
|
||||||
|
} else {
|
||||||
|
PathBuf::from(&data_dir_str).join("bichon-storage")
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("\n{}", style("Paths to be migrated:").bold());
|
||||||
|
println!("----------------------------------------");
|
||||||
|
println!(
|
||||||
|
"{:<20} : {}",
|
||||||
|
"bichon-root-dir",
|
||||||
|
style(root_path.display()).cyan()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{:<20} : {}",
|
||||||
|
"bichon-index-dir",
|
||||||
|
style(index_path.display()).cyan()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{:<20} : {}",
|
||||||
|
"bichon-data-dir",
|
||||||
|
style(data_path.display()).cyan()
|
||||||
|
);
|
||||||
|
println!("----------------------------------------");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{} Checking legacy v0.x storage layout...",
|
||||||
|
style("⌛").yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
match is_legacy_data_layout_with_paths(&index_path, &data_path) {
|
||||||
|
Ok(true) => {
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
style("✔").green(),
|
||||||
|
style("Legacy v0.3.7 Tantivy-based storage detected. Migration to v1.0 is required.")
|
||||||
|
.yellow()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
style("✔").green(),
|
||||||
|
style("No legacy v0.3.7 storage layout was detected at the specified paths.").green()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style(
|
||||||
|
"The selected directories may already be using the v1.0 storage architecture."
|
||||||
|
)
|
||||||
|
.dim()
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} Failed to verify legacy storage layout: {:?}",
|
||||||
|
style("ERROR:").red().bold(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{} {}",
|
||||||
|
style("⚠").yellow(),
|
||||||
|
style(
|
||||||
|
"This migration is non-destructive. Existing v0.x storage files will remain unchanged."
|
||||||
|
)
|
||||||
|
.yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !Confirm::with_theme(theme)
|
||||||
|
.with_prompt("Ready to migrate?")
|
||||||
|
.default(true)
|
||||||
|
.interact()
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
println!("{}", style("Migration cancelled.").dim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Migrate metadata (meta.db + mailbox.db → memdb)
|
||||||
|
match crate::meta::migrate_metadata(&root_path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"\n{} Metadata migration failed:\n{}",
|
||||||
|
style("✘").red().bold(),
|
||||||
|
style(e).red()
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
style("Aborting migration. No changes have been made to Tantivy data.").yellow()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{} {}",
|
||||||
|
style("⌛").yellow(),
|
||||||
|
style("Step 2: Migrating email index and blob data...").cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{} {}",
|
||||||
|
style("ℹ").blue(),
|
||||||
|
style("Batch size controls memory usage during migration:").dim()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} 1000 — ~500MB RAM (slower, low memory)",
|
||||||
|
style("•").dim()
|
||||||
|
);
|
||||||
|
println!(" {} 3000 — ~1GB RAM (recommended)", style("•").dim());
|
||||||
|
println!(
|
||||||
|
" {} 5000 — ~2GB RAM (faster, high memory)",
|
||||||
|
style("•").dim()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} Note: actual memory usage depends on your average email size.",
|
||||||
|
style("•").yellow()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} If your mailbox contains many large attachments, use a smaller batch size.\n",
|
||||||
|
style(" ").dim()
|
||||||
|
);
|
||||||
|
|
||||||
|
let batch_size: u32 = {
|
||||||
|
let input: String = Input::with_theme(&ColorfulTheme::default())
|
||||||
|
.with_prompt("Enter batch size (affects memory usage, see notes above)")
|
||||||
|
.default("3000".to_string())
|
||||||
|
.validate_with(|s: &String| match s.trim().parse::<usize>() {
|
||||||
|
Ok(n) if n > 0 => Ok(()),
|
||||||
|
_ => Err("Please enter a valid positive number"),
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap_or("3000".to_string());
|
||||||
|
input.trim().parse::<u32>().unwrap_or(3000)
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} Using batch size: {}\n",
|
||||||
|
style("✓").green(),
|
||||||
|
style(batch_size).cyan().bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} Using batch size: {}\n",
|
||||||
|
style("✓").green(),
|
||||||
|
style(batch_size).cyan().bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
let legacy = LegacyDirs::new(index_path.clone(), data_path.clone());
|
||||||
|
let total_segments = match count_eml_segments(&legacy) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"\n{} Failed to count EML segments:\n{:?}",
|
||||||
|
style("✘").red().bold(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if total_segments == 0 {
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
style("✔").green(),
|
||||||
|
style("No EML segments found. Nothing to migrate.").bold()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} EML segments to migrate: {}",
|
||||||
|
style("⌛").yellow(),
|
||||||
|
style(total_segments).cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
let pb = ProgressBar::new(total_segments as u64);
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template(
|
||||||
|
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars("#>-"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut grand_total_migrated: usize = 0;
|
||||||
|
let mut grand_total_skipped: usize = 0;
|
||||||
|
|
||||||
|
for seg_idx in 0..total_segments {
|
||||||
|
let seg_total: std::cell::Cell<usize> = std::cell::Cell::new(0);
|
||||||
|
|
||||||
|
pb.set_message(format!("Segment {}/{}", seg_idx + 1, total_segments));
|
||||||
|
let legacy = LegacyDirs::new(index_path.clone(), data_path.clone());
|
||||||
|
match do_migrate_segment(
|
||||||
|
batch_size,
|
||||||
|
legacy,
|
||||||
|
NewDirs::new(new_index_path.clone(), new_data_path.clone()),
|
||||||
|
seg_idx,
|
||||||
|
|msg| {
|
||||||
|
if let Some(data) = msg.strip_prefix("TOTAL:") {
|
||||||
|
seg_total.set(data.parse().unwrap_or(0));
|
||||||
|
} else if let Some(data) = msg.strip_prefix("PHASE1:") {
|
||||||
|
let parts: Vec<&str> = data.split('/').collect();
|
||||||
|
let scanned: usize = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let total: usize = parts
|
||||||
|
.get(1)
|
||||||
|
.and_then(|s| s.split_once(" skipped:").map(|(n, _)| n))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let skipped: usize = data
|
||||||
|
.split_once("skipped:")
|
||||||
|
.and_then(|(_, s)| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let pct = if total > 0 {
|
||||||
|
(scanned * 100) / total
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
pb.set_message(format!(
|
||||||
|
"Segment {}/{} [scanning {}/{} skipped:{} {}%]",
|
||||||
|
seg_idx + 1,
|
||||||
|
total_segments,
|
||||||
|
scanned,
|
||||||
|
total,
|
||||||
|
skipped,
|
||||||
|
pct,
|
||||||
|
));
|
||||||
|
} else if let Some(data) = msg.strip_prefix("PROGRESS:") {
|
||||||
|
let parts: Vec<&str> = data.split(':').collect();
|
||||||
|
let migrated: usize = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let total = seg_total.get();
|
||||||
|
let pct = if total > 0 {
|
||||||
|
(migrated * 100) / total
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
pb.set_message(format!(
|
||||||
|
"Segment {}/{} [migrating {}/{} {}%]",
|
||||||
|
seg_idx + 1,
|
||||||
|
total_segments,
|
||||||
|
migrated,
|
||||||
|
total,
|
||||||
|
pct,
|
||||||
|
));
|
||||||
|
} else if let Some(warn) = msg.strip_prefix("WARN:") {
|
||||||
|
pb.println(format!("{} {}", style("⚠").yellow(), warn));
|
||||||
|
} else if let Some(done_data) = msg.strip_prefix("DONE:") {
|
||||||
|
let parts: Vec<&str> = done_data.split(':').collect();
|
||||||
|
let migrated: usize = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let skipped: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
grand_total_migrated += migrated;
|
||||||
|
grand_total_skipped += skipped;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
pb.finish_with_message(format!("{}", style("Migration failed.").red()));
|
||||||
|
eprintln!("\n{} {:?}", style("✘").red().bold(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pb.set_position((seg_idx + 1) as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
pb.finish_with_message(format!(
|
||||||
|
"Migration finished. Total: {}, Skipped: {}",
|
||||||
|
grand_total_migrated, grand_total_skipped
|
||||||
|
));
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
style("✔").green(),
|
||||||
|
style("Migration completed successfully!").bold()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_legacy_data_layout_with_paths(
|
||||||
|
envelope_dir: &PathBuf,
|
||||||
|
eml_dir: &PathBuf,
|
||||||
|
) -> std::io::Result<bool> {
|
||||||
|
let envelope_result = is_tantivy_index_dir(envelope_dir)?;
|
||||||
|
let eml_result = is_tantivy_index_dir(eml_dir)?;
|
||||||
|
|
||||||
|
Ok(envelope_result || eml_result)
|
||||||
|
}
|
||||||
248
crates/admin/src/reset.rs
Normal file
248
crates/admin/src/reset.rs
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use bichon_core::{
|
||||||
|
admin::meta::{find_admin, open_database, update_admin_password},
|
||||||
|
utils::encrypt::internal_decrypt_string,
|
||||||
|
};
|
||||||
|
use console::{style, Emoji};
|
||||||
|
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
|
||||||
|
|
||||||
|
pub fn handle_reset_password(theme: &ColorfulTheme) {
|
||||||
|
let root_dir_str: String = Input::with_theme(theme)
|
||||||
|
.with_prompt("Enter the absolute path for 'bichon_root_dir'")
|
||||||
|
.validate_with(|input: &String| -> Result<(), &str> {
|
||||||
|
let path = Path::new(input);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("Path must be absolute.");
|
||||||
|
}
|
||||||
|
if !path.exists() {
|
||||||
|
return Err("Directory does not exist.");
|
||||||
|
}
|
||||||
|
let memdb_dir = path.join("memdb");
|
||||||
|
if !memdb_dir.exists() || !memdb_dir.is_dir() {
|
||||||
|
return Err("Invalid directory: 'memdb' data directory not found.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let root_path = PathBuf::from(&root_dir_str);
|
||||||
|
let database = open_database(&root_path.join("memdb")).unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"\n{} Failed to open database.",
|
||||||
|
style("ERROR:").red().bold()
|
||||||
|
);
|
||||||
|
eprintln!("Details: {:?}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let admin = find_admin(&database);
|
||||||
|
|
||||||
|
match admin {
|
||||||
|
Ok(Some(user)) => {
|
||||||
|
println!("\n{}", style("Admin user found:").green().bold());
|
||||||
|
println!("----------------------------------------");
|
||||||
|
println!("{:<12} : {}", "Username", style(&user.username).cyan());
|
||||||
|
println!("{:<12} : {}", "Email", style(&user.email).cyan());
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
style("ERROR: No admin user found in the database.")
|
||||||
|
.red()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
println!("Please ensure the system has been initialized correctly.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"\n{} Failed to query admin user.",
|
||||||
|
style("ERROR:").red().bold()
|
||||||
|
);
|
||||||
|
eprintln!("Details: {:?}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryption_key = loop {
|
||||||
|
let auth_methods = vec![
|
||||||
|
"Enter encryption password manually",
|
||||||
|
"Read from password file",
|
||||||
|
];
|
||||||
|
let method = Select::with_theme(theme)
|
||||||
|
.with_prompt("How would you like to provide the database encryption key?")
|
||||||
|
.items(&auth_methods)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let raw_key = if method == 0 {
|
||||||
|
Input::with_theme(theme)
|
||||||
|
.with_prompt("Enter Encryption Password")
|
||||||
|
.interact()
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
let file_path: String = Input::with_theme(theme)
|
||||||
|
.with_prompt("Enter path to encryption password file")
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match std::fs::read_to_string(&file_path) {
|
||||||
|
Ok(content) => content.trim().to_string(),
|
||||||
|
Err(e) => {
|
||||||
|
println!("{}: {}", style("Failed to read file").red(), e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if raw_key.is_empty() {
|
||||||
|
println!("{}", style("Key cannot be empty.").red());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt_message = format!(
|
||||||
|
"Encryption key loaded: [ {} ]\n\n \
|
||||||
|
{}: This key must match the database encryption key used by the server.\n \
|
||||||
|
It corresponds to these settings in your service:\n \
|
||||||
|
- Arguments: {} or {}\n \
|
||||||
|
- Envs: {} or {}\n\n \
|
||||||
|
Do you want to continue?",
|
||||||
|
style(&raw_key).cyan().bold(),
|
||||||
|
style("IMPORTANT").yellow().bold(),
|
||||||
|
style("--bichon_encrypt_password").italic(),
|
||||||
|
style("--bichon_encrypt_password_file").italic(),
|
||||||
|
style("BICHON_ENCRYPT_PASSWORD").green(),
|
||||||
|
style("BICHON_ENCRYPT_PASSWORD_FILE").green()
|
||||||
|
);
|
||||||
|
|
||||||
|
if Confirm::with_theme(theme)
|
||||||
|
.with_prompt(prompt_message)
|
||||||
|
.default(true)
|
||||||
|
.interact()
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
break raw_key;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let admin = find_admin(&database);
|
||||||
|
|
||||||
|
match admin {
|
||||||
|
Ok(Some(user)) => {
|
||||||
|
println!("----------------------------------------");
|
||||||
|
println!("{:<12} : {}", "Username", style(&user.username).cyan());
|
||||||
|
println!("{:<12} : {}", "Email", style(&user.email).cyan());
|
||||||
|
|
||||||
|
let pwd_display = match &user.password {
|
||||||
|
Some(p) => {
|
||||||
|
let password = match internal_decrypt_string(&encryption_key, p) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
println!("\n{}", style("ERROR: Decryption Failed").red().bold());
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style("The provided encryption key is incorrect or invalid for this database.").yellow()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{} Please verify your {} or the {} you provided.",
|
||||||
|
style("➔").cyan(),
|
||||||
|
style("encryption password").bold(),
|
||||||
|
style("key file").bold()
|
||||||
|
);
|
||||||
|
eprintln!("\nTechnical details: {:?}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
style(password).yellow().to_string()
|
||||||
|
}
|
||||||
|
None => style("None (No password set)").dim().italic().to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{:<12} : {}", "Password", pwd_display);
|
||||||
|
println!("----------------------------------------");
|
||||||
|
|
||||||
|
if !dialoguer::Confirm::with_theme(theme)
|
||||||
|
.with_prompt(format!(
|
||||||
|
"Do you want to reset the password for '{}'?",
|
||||||
|
user.username
|
||||||
|
))
|
||||||
|
.interact()
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
println!("Operation cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
style("ERROR: No admin user found in the database.")
|
||||||
|
.red()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
println!("Please ensure the system has been initialized correctly.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"\n{} Failed to query admin user.",
|
||||||
|
style("ERROR:").red().bold()
|
||||||
|
);
|
||||||
|
eprintln!("Details: {:?}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
style("TARGET: Reset password for user 'admin'")
|
||||||
|
.yellow()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_login_password = Password::with_theme(theme)
|
||||||
|
.with_prompt("Enter new Admin Login Password")
|
||||||
|
.with_confirmation("Repeat password to confirm", "Passwords do not match!")
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if !Confirm::with_theme(theme)
|
||||||
|
.with_prompt("Proceed with database update?")
|
||||||
|
.interact()
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{} {}", style("⌛").yellow(), "Updating database...");
|
||||||
|
|
||||||
|
match update_admin_password(&database, new_login_password, &encryption_key) {
|
||||||
|
Ok(_) => {
|
||||||
|
println!(
|
||||||
|
"\n{} {}",
|
||||||
|
Emoji("✨", "*"),
|
||||||
|
style("Success! Admin password has been updated.")
|
||||||
|
.green()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style("You can now log in with the new password.").dim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
style("ERROR: Failed to update database").red().bold()
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{} Could not save the new password to the database.",
|
||||||
|
style("➔").cyan()
|
||||||
|
);
|
||||||
|
eprintln!("\nDetails: {:?}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
crates/cli/Cargo.toml
Normal file
27
crates/cli/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "bichon-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bichon-core = { path = "../core" }
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
dialoguer.workspace = true
|
||||||
|
console.workspace = true
|
||||||
|
mail-parser.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
toml = "0.9.8"
|
||||||
|
memmap2 = "0.9.10"
|
||||||
|
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
|
||||||
|
compressed-rtf = "1.0.1"
|
||||||
|
chrono.workspace = true
|
||||||
|
mail-send.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
codepage-strings = "1.0.2"
|
||||||
|
hex = "0.4.3"
|
||||||
|
sysinfo.workspace = true
|
||||||
|
indicatif.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
81
crates/cli/src/api/download.rs
Normal file
81
crates/cli/src/api/download.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use crate::BichonCliConfig;
|
||||||
|
use bichon_core::{base64_encode, envelope::meta::BichonMetadata, store::envelope::Envelope};
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use reqwest::Client;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
pub async fn download_and_export_with_json_header(
|
||||||
|
client: &Client,
|
||||||
|
config: &BichonCliConfig,
|
||||||
|
envelope: Envelope,
|
||||||
|
file: &mut tokio::fs::File,
|
||||||
|
) -> bool {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/download-message/{}/{}",
|
||||||
|
config.base_url, &envelope.account_id, &envelope.id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = match client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) => {
|
||||||
|
if !res.status().is_success() {
|
||||||
|
eprintln!(
|
||||||
|
" ✘ HTTP Error {}: Failed for {}",
|
||||||
|
res.status(),
|
||||||
|
&envelope.id
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ✘ Network error: {} for {}", e, &envelope.id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let email_bytes = match response.bytes().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
" ✘ Failed to read response body for {}: {}",
|
||||||
|
&envelope.id, e
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let date_dt = Utc.timestamp_opt(envelope.date / 1000, 0).unwrap();
|
||||||
|
let date_str = date_dt.format("%a %b %e %H:%M:%S %Y").to_string();
|
||||||
|
let from_line = format!("From {} {}\n", envelope.from.clone(), date_str);
|
||||||
|
|
||||||
|
let custom_header = build_metadata_header(BichonMetadata {
|
||||||
|
account_email: envelope.account_email,
|
||||||
|
mailbox_name: envelope.mailbox_name,
|
||||||
|
tags: envelope.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut final_buffer =
|
||||||
|
Vec::with_capacity(from_line.len() + custom_header.len() + email_bytes.len() + 2);
|
||||||
|
final_buffer.extend_from_slice(from_line.as_bytes());
|
||||||
|
final_buffer.extend_from_slice(custom_header.as_bytes());
|
||||||
|
final_buffer.extend_from_slice(&email_bytes);
|
||||||
|
final_buffer.extend_from_slice(b"\n\n");
|
||||||
|
|
||||||
|
if let Err(e) = file.write_all(&final_buffer).await {
|
||||||
|
eprintln!(" ✘ IO Error: Failed to write to mbox: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_metadata_header(meta: BichonMetadata) -> String {
|
||||||
|
let json_str = serde_json::to_string(&meta).ok().unwrap();
|
||||||
|
let encoded = base64_encode!(json_str);
|
||||||
|
format!("X-Bichon-Metadata: {}\r\n", encoded)
|
||||||
|
}
|
||||||
4
crates/cli/src/api/mod.rs
Normal file
4
crates/cli/src/api/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod download;
|
||||||
|
pub mod search;
|
||||||
|
pub mod sender;
|
||||||
|
pub mod stats;
|
||||||
54
crates/cli/src/api/search.rs
Normal file
54
crates/cli/src/api/search.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use bichon_core::{
|
||||||
|
common::paginated::DataPage,
|
||||||
|
message::search::{EmailSearchFilter, EmailSearchRequest, SortBy},
|
||||||
|
store::envelope::Envelope,
|
||||||
|
};
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use crate::BichonCliConfig;
|
||||||
|
|
||||||
|
pub async fn search_messages(
|
||||||
|
client: &Client,
|
||||||
|
config: &BichonCliConfig,
|
||||||
|
page: u64,
|
||||||
|
page_size: u64,
|
||||||
|
) -> Option<DataPage<Envelope>> {
|
||||||
|
let url = format!("{}/api/v1/search-messages", config.base_url);
|
||||||
|
|
||||||
|
let payload = EmailSearchRequest {
|
||||||
|
filter: EmailSearchFilter::default(),
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
sort_by: Some(SortBy::DATE),
|
||||||
|
desc: Some(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
match client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) if res.status().is_success() => match res.json::<DataPage<Envelope>>().await {
|
||||||
|
Ok(data) => Some(data),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ✘ Failed to parse search response: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(res) => {
|
||||||
|
let status = res.status();
|
||||||
|
let error_body = res.text().await.unwrap_or_default();
|
||||||
|
eprintln!(
|
||||||
|
" ✘ Failed to search messages. Status: {}\n Server error: {}",
|
||||||
|
status, error_body
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ✘ Network error performing search: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
crates/cli/src/api/sender.rs
Normal file
77
crates/cli/src/api/sender.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use console::style;
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use bichon_core::import::BatchEmlRequest;
|
||||||
|
|
||||||
|
use crate::BichonCliConfig;
|
||||||
|
|
||||||
|
pub async fn send_batch_request(
|
||||||
|
client: &Client,
|
||||||
|
config: &BichonCliConfig,
|
||||||
|
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) => {
|
||||||
|
let status = res.status();
|
||||||
|
let error_body = res.text().await.unwrap_or_default();
|
||||||
|
eprintln!(
|
||||||
|
" {} Failed to send to [{}]. Status: {}\n Server error: {}",
|
||||||
|
style("✘").red(),
|
||||||
|
folder,
|
||||||
|
status,
|
||||||
|
error_body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
" {} Network error on [{}]: {}",
|
||||||
|
style("✘").red(),
|
||||||
|
folder,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
crates/cli/src/api/stats.rs
Normal file
48
crates/cli/src/api/stats.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use bichon_core::account::stats::AccountStats;
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use crate::BichonCliConfig;
|
||||||
|
|
||||||
|
pub async fn fetch_account_stats(
|
||||||
|
client: &Client,
|
||||||
|
config: &BichonCliConfig,
|
||||||
|
account_id: u64,
|
||||||
|
) -> Option<AccountStats> {
|
||||||
|
let url = format!("{}/api/v1/accounts/{}/stats", config.base_url, account_id);
|
||||||
|
|
||||||
|
match client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) if res.status().is_success() => {
|
||||||
|
match res.json::<AccountStats>().await {
|
||||||
|
Ok(stats) => Some(stats),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ✘ Failed to parse stats response: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(res) => {
|
||||||
|
let status = res.status();
|
||||||
|
let error_body = res.text().await.unwrap_or_default();
|
||||||
|
eprintln!(
|
||||||
|
" ✘ Failed to fetch stats for account [{}]. Status: {}\n Server error: {}",
|
||||||
|
account_id,
|
||||||
|
status,
|
||||||
|
error_body
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
" ✘ Network error fetching stats for [{}]: {}",
|
||||||
|
account_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,43 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use console::style;
|
use console::style;
|
||||||
use dialoguer::{theme::ColorfulTheme, Select};
|
use dialoguer::{theme::ColorfulTheme, Select};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
||||||
use crate::modules::{
|
use bichon_core::{
|
||||||
account::payload::MinimalAccount,
|
account::payload::MinimalAccount,
|
||||||
cli::BichonCtlConfig,
|
|
||||||
users::{permissions::Permission, view::UserView},
|
users::{permissions::Permission, view::UserView},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &ColorfulTheme) -> u64 {
|
use crate::BichonCliConfig;
|
||||||
let client = Client::new();
|
|
||||||
let url = format!("{}/api/v1/current-user", config.base_url);
|
|
||||||
|
|
||||||
|
async fn fetch_json<T: serde::de::DeserializeOwned>(
|
||||||
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
token: &str,
|
||||||
|
label: &str,
|
||||||
|
) -> T {
|
||||||
let response = match client
|
let response = match client
|
||||||
.get(&url)
|
.get(url)
|
||||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -36,19 +57,15 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let status = response.status();
|
||||||
let status = response.status();
|
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||||
let error_body = response
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| "No error detail provided".to_string());
|
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"\n{} Server returned an error (Status: {})",
|
"\n{} Server returned an error (Status: {})",
|
||||||
style("✘ API Error:").red().bold(),
|
style("✘ API Error:").red().bold(),
|
||||||
style(status).yellow()
|
style(status).yellow()
|
||||||
);
|
);
|
||||||
|
|
||||||
if status == 401 {
|
if status == 401 {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{} Your API Token seems to be invalid or expired.",
|
"{} Your API Token seems to be invalid or expired.",
|
||||||
@@ -60,36 +77,66 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
|||||||
style("Context:").dim()
|
style("Context:").dim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
eprintln!("{} {}", style("Response:").dim(), body);
|
||||||
eprintln!("{} {}", style("Response:").dim(), error_body);
|
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: UserView = response.json().await.expect("Failed to parse user data");
|
if body.is_empty() {
|
||||||
println!("Welcome, {}!", style(&user.username).cyan());
|
eprintln!(
|
||||||
|
"\n{} Server returned an empty response for [{}] (Status: {})",
|
||||||
let account_list_url = format!(
|
style("✘ Empty Response:").red().bold(),
|
||||||
"{}/api/v1/minimal-account-list?only_nosync=true",
|
label,
|
||||||
config.base_url
|
status
|
||||||
);
|
|
||||||
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()
|
|
||||||
);
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{} This may be caused by a reverse proxy or middleware issue.",
|
||||||
|
style("Tip:").cyan()
|
||||||
|
);
|
||||||
|
process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let accounts: Vec<MinimalAccount> = acc_response
|
match serde_json::from_str::<T>(&body) {
|
||||||
.json()
|
Ok(data) => data,
|
||||||
.await
|
Err(e) => {
|
||||||
.expect("Failed to parse minimal account list");
|
eprintln!(
|
||||||
|
"\n{} Failed to parse response for [{}]: {}",
|
||||||
|
style("✘ Parse Error:").red().bold(),
|
||||||
|
label,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
eprintln!("{} Raw body: {}", style("Debug:").dim(), body);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_user_and_get_account(
|
||||||
|
config: &BichonCliConfig,
|
||||||
|
theme: &ColorfulTheme,
|
||||||
|
only_nosync: bool,
|
||||||
|
) -> MinimalAccount {
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let user: UserView = fetch_json(
|
||||||
|
&client,
|
||||||
|
&format!("{}/api/v1/current-user", config.base_url),
|
||||||
|
&config.api_token,
|
||||||
|
"current-user",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
println!("Welcome, {}!", style(&user.username).cyan());
|
||||||
|
|
||||||
|
let accounts: Vec<MinimalAccount> = fetch_json(
|
||||||
|
&client,
|
||||||
|
&format!(
|
||||||
|
"{}/api/v1/minimal-account-list?only_nosync={only_nosync}",
|
||||||
|
config.base_url
|
||||||
|
),
|
||||||
|
&config.api_token,
|
||||||
|
"minimal-account-list",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
if accounts.is_empty() {
|
if accounts.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
@@ -106,6 +153,7 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
|||||||
);
|
);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let required_permission = Permission::DATA_IMPORT_BATCH;
|
let required_permission = Permission::DATA_IMPORT_BATCH;
|
||||||
let mut selectable_accounts = Vec::new();
|
let mut selectable_accounts = Vec::new();
|
||||||
let mut options = Vec::new();
|
let mut options = Vec::new();
|
||||||
@@ -165,5 +213,5 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
|||||||
style(&selected_acc.email).cyan().bold()
|
style(&selected_acc.email).cyan().bold()
|
||||||
);
|
);
|
||||||
|
|
||||||
selected_acc.id
|
selected_acc.clone()
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,21 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
fs,
|
fs,
|
||||||
@@ -9,13 +27,12 @@ use dialoguer::{theme::ColorfulTheme, Input};
|
|||||||
use mail_parser::MessageParser;
|
use mail_parser::MessageParser;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
||||||
use crate::{
|
use bichon_core::base64_encode_url_safe;
|
||||||
base64_encode_url_safe,
|
|
||||||
modules::cli::{sender::send_batch_request, BichonCtlConfig},
|
use crate::{BichonCliConfig, api::sender::send_batch_request};
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn handle_eml_directory_import(
|
pub async fn handle_eml_directory_import(
|
||||||
config: &BichonCtlConfig,
|
config: &BichonCliConfig,
|
||||||
account_id: u64,
|
account_id: u64,
|
||||||
theme: &ColorfulTheme,
|
theme: &ColorfulTheme,
|
||||||
) {
|
) {
|
||||||
@@ -83,7 +100,7 @@ fn scan_dir(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn process_and_upload(
|
async fn process_and_upload(
|
||||||
config: &BichonCtlConfig,
|
config: &BichonCliConfig,
|
||||||
account_id: u64,
|
account_id: u64,
|
||||||
tasks: HashMap<String, Vec<PathBuf>>,
|
tasks: HashMap<String, Vec<PathBuf>>,
|
||||||
) {
|
) {
|
||||||
212
crates/cli/src/export/mod.rs
Normal file
212
crates/cli/src/export/mod.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
use crate::api::download::download_and_export_with_json_header;
|
||||||
|
use crate::api::search::search_messages;
|
||||||
|
use crate::api::stats::fetch_account_stats;
|
||||||
|
use crate::BichonCliConfig;
|
||||||
|
use bichon_core::account::payload::MinimalAccount;
|
||||||
|
use console::style;
|
||||||
|
use dialoguer::Confirm;
|
||||||
|
use dialoguer::{theme::ColorfulTheme, Input};
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use sysinfo::Disks;
|
||||||
|
|
||||||
|
pub async fn handle_account_export(
|
||||||
|
config: &BichonCliConfig,
|
||||||
|
account: MinimalAccount,
|
||||||
|
theme: &ColorfulTheme,
|
||||||
|
) {
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
println!("Fetching account statistics...");
|
||||||
|
let stats = match fetch_account_stats(&client, config, account.id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
eprintln!("{} Failed to fetch account statistics.", style("✘").red());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("\n--- Account Statistics ---");
|
||||||
|
println!(" Total Emails: {}", style(stats.total_count).cyan());
|
||||||
|
println!(
|
||||||
|
" Total Size: {}",
|
||||||
|
style(format_bytes(stats.total_size)).cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
let path = loop {
|
||||||
|
let input: String = Input::with_theme(theme)
|
||||||
|
.with_prompt("Enter ABSOLUTE directory path for MBOX file")
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let p = PathBuf::from(&input);
|
||||||
|
|
||||||
|
if !p.is_absolute() {
|
||||||
|
eprintln!(
|
||||||
|
" {} {}",
|
||||||
|
style("✘").red(),
|
||||||
|
style("Invalid path: Must be an absolute path.").red()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.exists() {
|
||||||
|
eprintln!(
|
||||||
|
" {} {}",
|
||||||
|
style("✘").red(),
|
||||||
|
style("Invalid path: Directory does not exist.").red()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.is_dir() {
|
||||||
|
eprintln!(
|
||||||
|
" {} {}",
|
||||||
|
style("✘").red(),
|
||||||
|
style("Invalid path: The path provided is not a directory.").red()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break p;
|
||||||
|
};
|
||||||
|
|
||||||
|
let disks = Disks::new_with_refreshed_list();
|
||||||
|
let disk_result = disks
|
||||||
|
.list()
|
||||||
|
.iter()
|
||||||
|
.find(|d| path.starts_with(d.mount_point()))
|
||||||
|
.ok_or_else(|| "Could not identify the disk for the provided path.");
|
||||||
|
|
||||||
|
match disk_result {
|
||||||
|
Ok(disk) => {
|
||||||
|
let free_space = disk.available_space();
|
||||||
|
let required_space = (stats.total_size as f64 * 1.2) as u64;
|
||||||
|
|
||||||
|
if free_space < required_space {
|
||||||
|
eprintln!(
|
||||||
|
" {} Insufficient disk space (including 10% safety buffer)!\n Required: {} (Base: {})\n Available: {}",
|
||||||
|
style("✘").red(),
|
||||||
|
style(format_bytes(required_space)).yellow(),
|
||||||
|
style(format_bytes(stats.total_size)).yellow(),
|
||||||
|
style(format_bytes(free_space)).yellow()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} Disk space check passed. (Required: {}, Available: {})",
|
||||||
|
style("✔").green(),
|
||||||
|
style(format_bytes(required_space)).cyan(),
|
||||||
|
style(format_bytes(free_space)).cyan()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" {} {}", style("✘").red(), style(e).red());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mbox_file = get_unique_mbox_path(&path, account.id, &account.email);
|
||||||
|
if Confirm::with_theme(theme)
|
||||||
|
.with_prompt(format!(
|
||||||
|
"Export {} emails to '{}'?",
|
||||||
|
stats.total_count,
|
||||||
|
mbox_file.display()
|
||||||
|
))
|
||||||
|
.default(true)
|
||||||
|
.interact()
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
println!(
|
||||||
|
" {} Starting export ({} items per page)...",
|
||||||
|
style("✔").green(),
|
||||||
|
100
|
||||||
|
);
|
||||||
|
|
||||||
|
let pb = ProgressBar::new(stats.total_count as u64);
|
||||||
|
pb.set_style(ProgressStyle::with_template(
|
||||||
|
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}"
|
||||||
|
).unwrap());
|
||||||
|
|
||||||
|
let mut file = match tokio::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.create(true)
|
||||||
|
.open(&mbox_file)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ✘ Failed to open file '{}': {}", path.display(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let page_size = 100;
|
||||||
|
let mut current_page = 1;
|
||||||
|
let mut total_pages;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Some(batch) = search_messages(&client, config, current_page, page_size).await {
|
||||||
|
total_pages = batch.total_pages.unwrap();
|
||||||
|
|
||||||
|
pb.set_message(format!("Page {}/{}", current_page, total_pages));
|
||||||
|
|
||||||
|
for envelope in batch.items {
|
||||||
|
let success =
|
||||||
|
download_and_export_with_json_header(&client, config, envelope, &mut file)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
pb.finish_with_message("Failed");
|
||||||
|
eprintln!(" ✘ Failed to export an email. Aborting process...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pb.inc(1);
|
||||||
|
}
|
||||||
|
if current_page >= total_pages {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current_page += 1;
|
||||||
|
} else {
|
||||||
|
pb.finish_with_message("Error");
|
||||||
|
eprintln!(
|
||||||
|
" ✘ Failed to fetch page {}. Aborting process...",
|
||||||
|
current_page
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pb.finish();
|
||||||
|
println!(" {} Export complete!", style("✔").green());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_bytes(bytes: u64) -> String {
|
||||||
|
if bytes < 1024 {
|
||||||
|
format!("{:.2} B", bytes)
|
||||||
|
} else if bytes < 1024 * 1024 {
|
||||||
|
format!("{:.2} KB", bytes / 1024)
|
||||||
|
} else if bytes < 1024 * 1024 * 1024 {
|
||||||
|
format!("{:.2} MB", bytes / 1024 / 1024)
|
||||||
|
} else {
|
||||||
|
format!("{:.2} GB", bytes / 1024 / 1024 / 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_unique_mbox_path(base_dir: &Path, account_id: u64, email: &str) -> PathBuf {
|
||||||
|
let email_part = email.replace(' ', "_");
|
||||||
|
|
||||||
|
let mut base_name = format!("account_{}_{}", account_id, email_part);
|
||||||
|
if base_name.starts_with('.') {
|
||||||
|
base_name = format!("_{}", base_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut final_path = base_dir.join(format!("{}.mbox", base_name));
|
||||||
|
|
||||||
|
let mut counter = 1;
|
||||||
|
while final_path.exists() {
|
||||||
|
final_path = base_dir.join(format!("{}_{}.mbox", base_name, counter));
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
final_path
|
||||||
|
}
|
||||||
172
crates/cli/src/main.rs
Normal file
172
crates/cli/src/main.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use bichon_core::bichon_version;
|
||||||
|
use clap::Parser;
|
||||||
|
use console::style;
|
||||||
|
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::verify_user_and_get_account, eml::handle_eml_directory_import,
|
||||||
|
export::handle_account_export, mbox::handle_mbox_single_file_import, pst::handle_pst_import,
|
||||||
|
thunderbird::handle_thunderbird_import,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod eml;
|
||||||
|
pub mod export;
|
||||||
|
pub mod mbox;
|
||||||
|
pub mod pst;
|
||||||
|
pub mod thunderbird;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "bichon-cli",
|
||||||
|
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 BichonCliConfig {
|
||||||
|
pub base_url: String,
|
||||||
|
pub api_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cli = BichonCli::parse();
|
||||||
|
let theme = ColorfulTheme::default();
|
||||||
|
let config_path = &cli.config;
|
||||||
|
let mut current_config: Option<BichonCliConfig> = None;
|
||||||
|
|
||||||
|
if config_path.exists() {
|
||||||
|
if let Ok(content) = fs::read_to_string(config_path) {
|
||||||
|
if let Ok(config) = toml::from_str::<BichonCliConfig>(&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 = BichonCliConfig {
|
||||||
|
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 operations = &[
|
||||||
|
"1. Import: Upload email data to Bichon",
|
||||||
|
"2. Export: Download account data as MBOX file",
|
||||||
|
];
|
||||||
|
|
||||||
|
let op_idx = Select::with_theme(&theme)
|
||||||
|
.with_prompt("Select operation")
|
||||||
|
.items(operations)
|
||||||
|
.default(0)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match op_idx {
|
||||||
|
0 => {
|
||||||
|
let target_account = verify_user_and_get_account(&final_config, &theme, true).await;
|
||||||
|
|
||||||
|
let import_modes = &[
|
||||||
|
"1. EML: Scan directory recursively (Maintains folder structure)",
|
||||||
|
"2. MBOX: Single archive file (Stream from one file)",
|
||||||
|
"3. Thunderbird: Import from local profile directory",
|
||||||
|
"4. 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 => {
|
||||||
|
let target_account = verify_user_and_get_account(&final_config, &theme, false).await;
|
||||||
|
handle_account_export(&final_config, target_account, &theme).await;
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
pub fn determine_folder(labels_raw: &str) -> String {
|
pub fn determine_folder(labels_raw: &str) -> String {
|
||||||
419
crates/cli/src/mbox/mod.rs
Normal file
419
crates/cli/src/mbox/mod.rs
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::api::sender::send_batch_request;
|
||||||
|
use crate::mbox::gmail::determine_folder;
|
||||||
|
use crate::mbox::reader::MboxFile;
|
||||||
|
use crate::BichonCliConfig;
|
||||||
|
use bichon_core::base64_encode_url_safe;
|
||||||
|
use bichon_core::envelope::meta::{parse_bichon_metadata, BichonMetadata};
|
||||||
|
use console::style;
|
||||||
|
use dialoguer::{theme::ColorfulTheme, Input};
|
||||||
|
use dialoguer::{Confirm, Select};
|
||||||
|
use mail_parser::parsers::MessageStream;
|
||||||
|
use mail_parser::MessageParser;
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
/// Skip emails larger than this with a warning (100 MB).
|
||||||
|
const MAX_EMAIL_BYTES: usize = 100 * 1024 * 1024;
|
||||||
|
/// Flush a folder buffer when accumulated base64 bytes exceed this (200 MB).
|
||||||
|
const MAX_BUFFER_BYTES: usize = 200 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub mod gmail;
|
||||||
|
pub mod reader;
|
||||||
|
|
||||||
|
pub async fn handle_mbox_single_file_import(
|
||||||
|
config: &BichonCliConfig,
|
||||||
|
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",
|
||||||
|
"Use X-Bichon-Metadata header (Automatic)",
|
||||||
|
];
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
2 => None,
|
||||||
|
_ => 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: &BichonCliConfig,
|
||||||
|
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 mut total_buffered_bytes: usize = 0;
|
||||||
|
let batch_limit = 50;
|
||||||
|
let mut skipped_count: u64 = 0;
|
||||||
|
|
||||||
|
println!("Starting import process...");
|
||||||
|
|
||||||
|
for (index, e) in mbox.iter().enumerate() {
|
||||||
|
let msg_num = index + 1;
|
||||||
|
let body = e.data;
|
||||||
|
|
||||||
|
if body.len() > MAX_EMAIL_BYTES {
|
||||||
|
let size_mb = body.len() as f64 / 1024.0 / 1024.0;
|
||||||
|
eprintln!(
|
||||||
|
"{} {}: email #{} is {:.1} MB (limit 100 MB). Skipping...",
|
||||||
|
style("Warning").yellow().bold(),
|
||||||
|
style(format!("oversized")).dim(),
|
||||||
|
msg_num,
|
||||||
|
size_mb,
|
||||||
|
);
|
||||||
|
skipped_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = match MessageParser::new().parse(body) {
|
||||||
|
Some(msg) => msg,
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"{} {}: {}",
|
||||||
|
style("Warning").yellow().bold(),
|
||||||
|
style(format!("at message #{}", msg_num)).dim(),
|
||||||
|
"Failed to parse email structure. Skipping..."
|
||||||
|
);
|
||||||
|
skipped_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut metadata: Option<BichonMetadata> = None;
|
||||||
|
if let Some(meta_header) = message.header_raw("X-Bichon-Metadata") {
|
||||||
|
metadata = parse_bichon_metadata(meta_header);
|
||||||
|
}
|
||||||
|
|
||||||
|
let get_default_folder = || {
|
||||||
|
let gmail_labels = message.header_raw("X-Gmail-Labels").unwrap_or("INBOX");
|
||||||
|
let text_cow = MessageStream::new(gmail_labels.as_bytes())
|
||||||
|
.parse_unstructured()
|
||||||
|
.into_text();
|
||||||
|
let data: &str = match &text_cow {
|
||||||
|
Some(c) => c.as_ref(),
|
||||||
|
None => "INBOX",
|
||||||
|
};
|
||||||
|
determine_folder(data)
|
||||||
|
};
|
||||||
|
|
||||||
|
let folder_name = if let Some(ref folder) = target_folder {
|
||||||
|
folder.clone()
|
||||||
|
} else if let Some(ref meta) = metadata {
|
||||||
|
meta.mailbox_name.clone().unwrap_or_else(get_default_folder)
|
||||||
|
} else {
|
||||||
|
get_default_folder()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drop message before base64-encoding to free MIME parse memory.
|
||||||
|
drop(message);
|
||||||
|
|
||||||
|
let b64_eml = base64_encode_url_safe!(&body);
|
||||||
|
let encoded_len = b64_eml.len();
|
||||||
|
|
||||||
|
let buffer = folder_buffers
|
||||||
|
.entry(folder_name.clone())
|
||||||
|
.or_insert_with(Vec::new);
|
||||||
|
buffer.push(b64_eml);
|
||||||
|
total_buffered_bytes += encoded_len;
|
||||||
|
|
||||||
|
if buffer.len() >= batch_limit || total_buffered_bytes >= MAX_BUFFER_BYTES {
|
||||||
|
let emls_to_send = folder_buffers.remove(&folder_name).unwrap();
|
||||||
|
let freed: usize = emls_to_send.iter().map(|s| s.len()).sum();
|
||||||
|
total_buffered_bytes = total_buffered_bytes.saturating_sub(freed);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if skipped_count > 0 {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style(format!(
|
||||||
|
"Skipped {} email(s) (oversized or unparseable).",
|
||||||
|
skipped_count
|
||||||
|
))
|
||||||
|
.yellow()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", style("Import completed successfully!").green().bold());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Fake sender: records every flushed batch as (folder_name, email_count, total_bytes).
|
||||||
|
struct FakeSender {
|
||||||
|
batches: Vec<(String, usize, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeSender {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { batches: vec![] }
|
||||||
|
}
|
||||||
|
fn send(&mut self, folder: &str, emls: Vec<String>) {
|
||||||
|
let count = emls.len();
|
||||||
|
let bytes: usize = emls.iter().map(|s| s.len()).sum();
|
||||||
|
self.batches.push((folder.to_string(), count, bytes));
|
||||||
|
// emls is dropped here, simulating real send
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_encode(size: usize) -> String {
|
||||||
|
// base64 expands ~1.33x, so the encoded string is roughly this long.
|
||||||
|
// We just need a predictable byte size, so use a repeated character.
|
||||||
|
"x".repeat(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_on_global_byte_threshold() {
|
||||||
|
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
let mut total_bytes: usize = 0;
|
||||||
|
let batch_limit = 50;
|
||||||
|
let mut sender = FakeSender::new();
|
||||||
|
|
||||||
|
// Simulate 3 emails, each 80 MB encoded, spread across 3 folders.
|
||||||
|
// After each email, global total goes up by 80 MB.
|
||||||
|
// After the 3rd email: 240 MB > 200 MB → flush the folder that got the 3rd email.
|
||||||
|
let emails = vec![
|
||||||
|
("Inbox", 80_000_000),
|
||||||
|
("Sent", 80_000_000),
|
||||||
|
("Archive", 80_000_000),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (folder, eml_size) in emails {
|
||||||
|
let encoded = fake_encode(eml_size);
|
||||||
|
let len = encoded.len();
|
||||||
|
let buffer = buffers.entry(folder.to_string()).or_insert_with(Vec::new);
|
||||||
|
buffer.push(encoded);
|
||||||
|
total_bytes += len;
|
||||||
|
|
||||||
|
if buffer.len() >= batch_limit || total_bytes >= MAX_BUFFER_BYTES {
|
||||||
|
let sent = buffers.remove(folder).unwrap();
|
||||||
|
let freed: usize = sent.iter().map(|s| s.len()).sum();
|
||||||
|
total_bytes = total_bytes.saturating_sub(freed);
|
||||||
|
sender.send(folder, sent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 3rd email should trigger a global flush of "Archive".
|
||||||
|
assert_eq!(sender.batches.len(), 1);
|
||||||
|
assert_eq!(sender.batches[0].0, "Archive");
|
||||||
|
assert_eq!(sender.batches[0].1, 1);
|
||||||
|
// "Inbox" and "Sent" are still buffered (160 MB total).
|
||||||
|
assert_eq!(buffers.len(), 2);
|
||||||
|
assert!(buffers.contains_key("Inbox"));
|
||||||
|
assert!(buffers.contains_key("Sent"));
|
||||||
|
assert_eq!(total_bytes, 160_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_on_count_threshold() {
|
||||||
|
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
let mut total_bytes: usize = 0;
|
||||||
|
let batch_limit = 3;
|
||||||
|
let mut sender = FakeSender::new();
|
||||||
|
|
||||||
|
// 4 small emails all to Inbox, well under byte threshold.
|
||||||
|
for _ in 0..4 {
|
||||||
|
let encoded = fake_encode(100); // tiny
|
||||||
|
let len = encoded.len();
|
||||||
|
let buffer = buffers
|
||||||
|
.entry("Inbox".to_string())
|
||||||
|
.or_insert_with(Vec::new);
|
||||||
|
buffer.push(encoded);
|
||||||
|
total_bytes += len;
|
||||||
|
|
||||||
|
if buffer.len() >= batch_limit || total_bytes >= MAX_BUFFER_BYTES {
|
||||||
|
let sent = buffers.remove("Inbox").unwrap();
|
||||||
|
let freed: usize = sent.iter().map(|s| s.len()).sum();
|
||||||
|
total_bytes = total_bytes.saturating_sub(freed);
|
||||||
|
sender.send("Inbox", sent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count=3 should trigger flush once; the 4th email stays buffered.
|
||||||
|
assert_eq!(sender.batches.len(), 1);
|
||||||
|
assert_eq!(sender.batches[0].1, 3); // 3 emails flushed
|
||||||
|
let remaining = buffers.get("Inbox").unwrap();
|
||||||
|
assert_eq!(remaining.len(), 1); // 1 still buffered
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_bytes_exact_boundary() {
|
||||||
|
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
let mut total_bytes: usize = 0;
|
||||||
|
let mut sender = FakeSender::new();
|
||||||
|
|
||||||
|
// Push one email that puts us right at 200 MB.
|
||||||
|
let encoded = fake_encode(MAX_BUFFER_BYTES);
|
||||||
|
let len = encoded.len();
|
||||||
|
buffers
|
||||||
|
.entry("Inbox".to_string())
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(encoded);
|
||||||
|
total_bytes += len;
|
||||||
|
|
||||||
|
if total_bytes >= MAX_BUFFER_BYTES {
|
||||||
|
let sent = buffers.remove("Inbox").unwrap();
|
||||||
|
let freed: usize = sent.iter().map(|s| s.len()).sum();
|
||||||
|
total_bytes = total_bytes.saturating_sub(freed);
|
||||||
|
sender.send("Inbox", sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have flushed on the boundary.
|
||||||
|
assert_eq!(sender.batches.len(), 1);
|
||||||
|
assert_eq!(total_bytes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_one_folder_does_not_lose_others() {
|
||||||
|
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
let mut total_bytes: usize = 0;
|
||||||
|
let batch_limit = 50;
|
||||||
|
let mut sender = FakeSender::new();
|
||||||
|
|
||||||
|
// Build up A to 150 MB, B to 100 MB (total 250 MB > 200 MB).
|
||||||
|
// A should trigger flush; B should stay buffered.
|
||||||
|
let folder_a = "A".to_string();
|
||||||
|
let folder_b = "B".to_string();
|
||||||
|
|
||||||
|
// Folder A: 150 MB
|
||||||
|
let encoded = fake_encode(150_000_000);
|
||||||
|
let len = encoded.len();
|
||||||
|
buffers.entry(folder_a.clone()).or_insert_with(Vec::new).push(encoded);
|
||||||
|
total_bytes += len;
|
||||||
|
|
||||||
|
// Folder B: 100 MB → total 250 MB → trigger flush on B
|
||||||
|
let encoded = fake_encode(100_000_000);
|
||||||
|
let len = encoded.len();
|
||||||
|
buffers.entry(folder_b.clone()).or_insert_with(Vec::new).push(encoded);
|
||||||
|
total_bytes += len;
|
||||||
|
|
||||||
|
// Check trigger on B
|
||||||
|
let b_buffer = buffers.get(&folder_b).unwrap();
|
||||||
|
if b_buffer.len() >= batch_limit || total_bytes >= MAX_BUFFER_BYTES {
|
||||||
|
let sent = buffers.remove(&folder_b).unwrap();
|
||||||
|
let freed: usize = sent.iter().map(|s| s.len()).sum();
|
||||||
|
total_bytes = total_bytes.saturating_sub(freed);
|
||||||
|
sender.send(&folder_b, sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(sender.batches.len(), 1);
|
||||||
|
assert_eq!(sender.batches[0].0, "B"); // B flushed
|
||||||
|
assert!(buffers.contains_key("A")); // A still there
|
||||||
|
assert_eq!(total_bytes, 150_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skip_oversized_email() {
|
||||||
|
assert!(100 <= MAX_EMAIL_BYTES);
|
||||||
|
// Use vec! so the 100 MB array lives on the heap, not the stack.
|
||||||
|
let huge = vec![0u8; MAX_EMAIL_BYTES + 1];
|
||||||
|
assert!(huge.len() > MAX_EMAIL_BYTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,21 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
use memmap2::Mmap;
|
use memmap2::Mmap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -111,7 +129,7 @@ impl<'a> Iterator for MboxReader<'a> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use mail_parser::MessageParser;
|
use mail_parser::MessageParser;
|
||||||
|
|
||||||
use crate::modules::cli::mbox::gmail::determine_folder;
|
use crate::mbox::gmail::determine_folder;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -202,7 +220,6 @@ mod tests {
|
|||||||
labels,
|
labels,
|
||||||
determine_folder(labels)
|
determine_folder(labels)
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
use compressed_rtf::*;
|
use compressed_rtf::*;
|
||||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||||
|
|
||||||
@@ -1,3 +1,21 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
use dialoguer::theme::ColorfulTheme;
|
use dialoguer::theme::ColorfulTheme;
|
||||||
use dialoguer::Input;
|
use dialoguer::Input;
|
||||||
@@ -5,10 +23,10 @@ use mail_send::mail_builder::headers::text::Text;
|
|||||||
use mail_send::mail_builder::MessageBuilder;
|
use mail_send::mail_builder::MessageBuilder;
|
||||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||||
|
|
||||||
use crate::base64_encode_url_safe;
|
use crate::api::sender::send_batch_request;
|
||||||
use crate::modules::cli::pst::encoding::decode_subject;
|
use crate::pst::encoding::decode_subject;
|
||||||
use crate::modules::cli::sender::send_batch_request;
|
use crate::BichonCliConfig;
|
||||||
use crate::modules::cli::BichonCtlConfig;
|
use bichon_core::base64_encode_url_safe;
|
||||||
use dialoguer::Confirm;
|
use dialoguer::Confirm;
|
||||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||||
use outlook_pst::messaging::folder::Folder;
|
use outlook_pst::messaging::folder::Folder;
|
||||||
@@ -42,7 +60,7 @@ pub struct EmailAttachment {
|
|||||||
pub data: Option<Vec<u8>>,
|
pub data: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) {
|
pub async fn handle_pst_import(config: &BichonCliConfig, account_id: u64, theme: &ColorfulTheme) {
|
||||||
let path_str: String = Input::with_theme(theme)
|
let path_str: String = Input::with_theme(theme)
|
||||||
.with_prompt("Enter the path to your SINGLE .pst file")
|
.with_prompt("Enter the path to your SINGLE .pst file")
|
||||||
.validate_with(|input: &String| {
|
.validate_with(|input: &String| {
|
||||||
@@ -97,7 +115,7 @@ pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) {
|
async fn parse_pst(pst_path: PathBuf, config: &BichonCliConfig, account_id: u64) {
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
|
||||||
let pst_store = match outlook_pst::open_store(&pst_path) {
|
let pst_store = match outlook_pst::open_store(&pst_path) {
|
||||||
@@ -143,7 +161,7 @@ fn process_folder_recursively<'a>(
|
|||||||
client: &'a Client,
|
client: &'a Client,
|
||||||
folder: &'a Rc<dyn Folder>,
|
folder: &'a Rc<dyn Folder>,
|
||||||
parent_path: &'a str,
|
parent_path: &'a str,
|
||||||
config: &'a BichonCtlConfig,
|
config: &'a BichonCliConfig,
|
||||||
account_id: u64,
|
account_id: u64,
|
||||||
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
|
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
@@ -254,7 +272,8 @@ fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let from = extract_string_property(properties, 0x5D01)
|
let from = extract_string_property(properties, 0x5D01)
|
||||||
.or_else(|| extract_string_property(properties, 0x5D02));
|
.or_else(|| extract_string_property(properties, 0x5D02))
|
||||||
|
.or_else(|| extract_string_property(properties, 0x0C1F));
|
||||||
|
|
||||||
if let Some(f) = from {
|
if let Some(f) = from {
|
||||||
builder = builder.from(f);
|
builder = builder.from(f);
|
||||||
@@ -341,48 +360,54 @@ fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<Strin
|
|||||||
let mut bcc = Vec::new();
|
let mut bcc = Vec::new();
|
||||||
|
|
||||||
let recipient_table = message.recipient_table();
|
let recipient_table = message.recipient_table();
|
||||||
let context = recipient_table.context();
|
if let Some(recipient_table) = 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 row in recipient_table.rows_matrix() {
|
for (col, val) in context.columns().iter().zip(cols) {
|
||||||
if let Ok(cols) = row.columns(context) {
|
let prop_val = val
|
||||||
let mut r_type = 0;
|
.as_ref()
|
||||||
let mut email = String::new();
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (col, val) in context.columns().iter().zip(cols) {
|
if !email.is_empty() {
|
||||||
let prop_val = val
|
match r_type {
|
||||||
.as_ref()
|
1 => to.push(email),
|
||||||
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
|
2 => cc.push(email),
|
||||||
match col.prop_id() {
|
3 => bcc.push(email),
|
||||||
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),
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
let receiver = extract_string_property(message.properties(), 0x0076);
|
||||||
|
if let Some(receiver) = receiver {
|
||||||
|
to.push(receiver);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(to, cc, bcc)
|
(to, cc, bcc)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_to_bichon(
|
async fn send_to_bichon(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
config: &BichonCtlConfig,
|
config: &BichonCliConfig,
|
||||||
account_id: u64,
|
account_id: u64,
|
||||||
folder_path: &str,
|
folder_path: &str,
|
||||||
emls: Vec<String>,
|
emls: Vec<String>,
|
||||||
@@ -1,11 +1,29 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
use std::{collections::HashMap, path::PathBuf};
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
|
|
||||||
use crate::modules::cli::{mbox::run_import, BichonCtlConfig};
|
use crate::{mbox::run_import, BichonCliConfig};
|
||||||
use console::style;
|
use console::style;
|
||||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||||
|
|
||||||
pub async fn handle_thunderbird_import(
|
pub async fn handle_thunderbird_import(
|
||||||
config: &BichonCtlConfig,
|
config: &BichonCliConfig,
|
||||||
account_id: u64,
|
account_id: u64,
|
||||||
theme: &ColorfulTheme,
|
theme: &ColorfulTheme,
|
||||||
) {
|
) {
|
||||||
73
crates/core/Cargo.toml
Normal file
73
crates/core/Cargo.toml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
[package]
|
||||||
|
name = "bichon-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["web-api"]
|
||||||
|
web-api = ["dep:poem-openapi"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
poem-openapi = { version = "5.1.16", features = [
|
||||||
|
"openapi-explorer",
|
||||||
|
"rapidoc",
|
||||||
|
"scalar",
|
||||||
|
"redoc",
|
||||||
|
"swagger-ui",
|
||||||
|
"email",
|
||||||
|
], optional = true }
|
||||||
|
chrono.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
memdb.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
ring.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-appender.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
snafu.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
tokio-socks.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
|
email_address.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
utf7-imap.workspace = true
|
||||||
|
mail-parser.workspace = true
|
||||||
|
tokio-rustls.workspace = true
|
||||||
|
oauth2.workspace = true
|
||||||
|
sysinfo.workspace = true
|
||||||
|
num_cpus.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
encoding_rs.workspace = true
|
||||||
|
async-imap = { git = "https://github.com/rustmailer/async-imap.git", branch = "main", default-features = false, features = [
|
||||||
|
"runtime-tokio",
|
||||||
|
"compress",
|
||||||
|
] }
|
||||||
|
tantivy = { version = "0.26.1", features = ["zstd-compression", "quickwit"] }
|
||||||
|
webpki-roots.workspace = true
|
||||||
|
rustls.workspace = true
|
||||||
|
rustls-pki-types.workspace = true
|
||||||
|
tokio-io-timeout.workspace = true
|
||||||
|
governor.workspace = true
|
||||||
|
lru.workspace = true
|
||||||
|
time.workspace = true
|
||||||
|
murmur3.workspace = true
|
||||||
|
dashmap.workspace = true
|
||||||
|
itoa.workspace = true
|
||||||
|
html2text.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
mail-send.workspace = true
|
||||||
|
blake3.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
fjall.workspace = true
|
||||||
|
tracing-log.workspace = true
|
||||||
|
tokio-util.workspace = true
|
||||||
|
whichlang = "0.1.1"
|
||||||
|
deunicode = "1.6.2"
|
||||||
|
scopeguard = "1.2.0"
|
||||||
|
quick-xml = { version = "0.40.0", features = ["serialize"] }
|
||||||
|
hickory-resolver = "0.26.0-alpha.1"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,19 +16,25 @@
|
|||||||
// 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::{encrypt, error::BichonResult};
|
||||||
|
|
||||||
use crate::{encrypt, modules::error::BichonResult};
|
//use poem_openapi::{Enum, Object};
|
||||||
|
|
||||||
use poem_openapi::{Enum, Object};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Object)]
|
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct ImapConfig {
|
pub struct ImapConfig {
|
||||||
/// IMAP server hostname or IP address
|
/// IMAP server hostname or IP address
|
||||||
#[oai(validator(max_length = 253, pattern = r"^[a-zA-Z0-9\-\.]+$"))]
|
#[cfg_attr(
|
||||||
|
feature = "web-api",
|
||||||
|
oai(validator(max_length = 253, pattern = r"^[a-zA-Z0-9\-\.]+$"))
|
||||||
|
)]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
/// IMAP server port number
|
/// IMAP server port number
|
||||||
#[oai(validator(minimum(value = "1"), maximum(value = "65535")))]
|
#[cfg_attr(
|
||||||
|
feature = "web-api",
|
||||||
|
oai(validator(minimum(value = "1"), maximum(value = "65535")))
|
||||||
|
)]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Connection encryption method
|
/// Connection encryption method
|
||||||
pub encryption: Encryption,
|
pub encryption: Encryption,
|
||||||
@@ -52,8 +58,8 @@ impl ImapConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
#[derive(Enum, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
pub enum AuthType {
|
pub enum AuthType {
|
||||||
/// Standard password authentication (PLAIN/LOGIN)
|
/// Standard password authentication (PLAIN/LOGIN)
|
||||||
#[default]
|
#[default]
|
||||||
@@ -62,7 +68,8 @@ pub enum AuthType {
|
|||||||
OAuth2,
|
OAuth2,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Object, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct AuthConfig {
|
pub struct AuthConfig {
|
||||||
///Authentication method to use
|
///Authentication method to use
|
||||||
pub auth_type: AuthType,
|
pub auth_type: AuthType,
|
||||||
@@ -70,7 +77,7 @@ pub struct AuthConfig {
|
|||||||
///
|
///
|
||||||
/// Users should provide a plaintext password (1 to 256 characters).
|
/// Users should provide a plaintext password (1 to 256 characters).
|
||||||
/// The server will encrypt the password using AES-256-GCM and securely store it.
|
/// The server will encrypt the password using AES-256-GCM and securely store it.
|
||||||
#[oai(validator(max_length = 256, min_length = 1))]
|
#[cfg_attr(feature = "web-api", oai(validator(max_length = 256, min_length = 1)))]
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +104,8 @@ impl AuthConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Enum)]
|
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
pub enum Encryption {
|
pub enum Encryption {
|
||||||
/// SSL/TLS encrypted connection
|
/// SSL/TLS encrypted connection
|
||||||
#[default]
|
#[default]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,14 +16,15 @@
|
|||||||
// 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 poem_openapi::Object;
|
//use poem_openapi::Object;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
modules::{
|
raise_error, utc_now,
|
||||||
|
{
|
||||||
account::migration::AccountModel,
|
account::migration::AccountModel,
|
||||||
common::auth::ClientContext,
|
common::auth::ClientContext,
|
||||||
database::{manager::DB_MANAGER, with_transaction},
|
database::{manager::DB_MANAGER, with_transaction, MemDbModel},
|
||||||
error::{code::ErrorCode, BichonResult},
|
error::{code::ErrorCode, BichonResult},
|
||||||
users::{
|
users::{
|
||||||
permissions::Permission,
|
permissions::Permission,
|
||||||
@@ -31,10 +32,10 @@ use crate::{
|
|||||||
UserModel,
|
UserModel,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
raise_error, utc_now,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct BatchAccountRoleRequest {
|
pub struct BatchAccountRoleRequest {
|
||||||
pub account_ids: Vec<u64>,
|
pub account_ids: Vec<u64>,
|
||||||
pub user_ids: Vec<u64>,
|
pub user_ids: Vec<u64>,
|
||||||
@@ -42,8 +43,8 @@ pub struct BatchAccountRoleRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BatchAccountRoleRequest {
|
impl BatchAccountRoleRequest {
|
||||||
pub async fn validate_existence(&self) -> BichonResult<()> {
|
pub fn validate_existence(&self) -> BichonResult<()> {
|
||||||
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
|
let role = UserRole::find(self.role_id)?.ok_or_else(|| {
|
||||||
raise_error!(
|
raise_error!(
|
||||||
format!("Role ID {} not found", self.role_id),
|
format!("Role ID {} not found", self.role_id),
|
||||||
ErrorCode::ResourceNotFound
|
ErrorCode::ResourceNotFound
|
||||||
@@ -58,7 +59,7 @@ impl BatchAccountRoleRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for id in &self.account_ids {
|
for id in &self.account_ids {
|
||||||
let exists = AccountModel::find(*id).await?; // Assuming an exists helper
|
let exists = AccountModel::find(*id)?; // Assuming an exists helper
|
||||||
if exists.is_none() {
|
if exists.is_none() {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
format!("Account ID {} not found", id),
|
format!("Account ID {} not found", id),
|
||||||
@@ -68,7 +69,7 @@ impl BatchAccountRoleRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for id in &self.user_ids {
|
for id in &self.user_ids {
|
||||||
let exists = UserModel::find(*id).await?; // Assuming an exists helper
|
let exists = UserModel::find(*id)?; // Assuming an exists helper
|
||||||
if exists.is_none() {
|
if exists.is_none() {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
format!("User ID {} not found", id),
|
format!("User ID {} not found", id),
|
||||||
@@ -80,44 +81,38 @@ impl BatchAccountRoleRequest {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn grant_batch_account_access(
|
fn grant_batch_account_access(
|
||||||
account_ids: Vec<u64>,
|
account_ids: Vec<u64>,
|
||||||
user_ids: Vec<u64>,
|
user_ids: Vec<u64>,
|
||||||
role_id: u64,
|
role_id: u64,
|
||||||
) -> BichonResult<()> {
|
) -> BichonResult<()> {
|
||||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
with_transaction(DB_MANAGER.db(), move |txn| {
|
||||||
|
let mut txn = txn;
|
||||||
for &uid in &user_ids {
|
for &uid in &user_ids {
|
||||||
// Fetch the current user record from the database
|
let db = DB_MANAGER.db();
|
||||||
let user = rw
|
let coll = db.collection(UserModel::collection());
|
||||||
.get()
|
let key = uid.to_string();
|
||||||
.primary::<UserModel>(uid)
|
let user: UserModel = coll
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
.get_required(&key)
|
||||||
.ok_or_else(|| {
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
raise_error!(
|
|
||||||
format!("User with id={} not found.", uid),
|
|
||||||
ErrorCode::ResourceNotFound
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut updated_user = user.clone();
|
let mut updated_user = user.clone();
|
||||||
|
|
||||||
// Apply the role to each specified account_id
|
|
||||||
for &aid in &account_ids {
|
for &aid in &account_ids {
|
||||||
updated_user.account_access_map.insert(aid, role_id);
|
updated_user.account_access_map.insert(aid, role_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
updated_user.updated_at = utc_now!();
|
updated_user.updated_at = utc_now!();
|
||||||
|
|
||||||
// Save the updated user back to the database within the transaction
|
txn = txn
|
||||||
rw.update(user, updated_user)
|
.upsert(UserModel::collection(), key, &updated_user)
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(txn)
|
||||||
})
|
})
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
|
pub fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
|
||||||
for account_id in &self.account_ids {
|
for account_id in &self.account_ids {
|
||||||
// Get the user's specific access for this account
|
// Get the user's specific access for this account
|
||||||
let assigned_role_id =
|
let assigned_role_id =
|
||||||
@@ -133,7 +128,7 @@ impl BatchAccountRoleRequest {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Fetch the role definition from the database
|
// Fetch the role definition from the database
|
||||||
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
|
let user_scoped_role = UserRole::find(*assigned_role_id)?.ok_or_else(|| {
|
||||||
raise_error!(
|
raise_error!(
|
||||||
"Assigned account role no longer exists".into(),
|
"Assigned account role no longer exists".into(),
|
||||||
ErrorCode::InternalError
|
ErrorCode::InternalError
|
||||||
@@ -155,6 +150,6 @@ impl BatchAccountRoleRequest {
|
|||||||
// This is where you'd compare target_role.permissions vs manager's perms
|
// This is where you'd compare target_role.permissions vs manager's perms
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
|
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
441
crates/core/src/account/migration.rs
Normal file
441
crates/core/src/account/migration.rs
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
account::{
|
||||||
|
entity::ImapConfig,
|
||||||
|
payload::{AccountCreateRequest, AccountUpdateRequest, MinimalAccount},
|
||||||
|
since::{DateSince, RelativeDate},
|
||||||
|
state::DownloadState,
|
||||||
|
},
|
||||||
|
cache::imap::{mailbox::MailBox, task::SYNC_TASKS},
|
||||||
|
common::paginated::DataPage,
|
||||||
|
context::controller::DOWNLOAD_CONTROLLER,
|
||||||
|
database::{
|
||||||
|
count_impl, delete_impl, find_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
|
||||||
|
paginate_impl, update_impl, MemDbModel,
|
||||||
|
},
|
||||||
|
encrypt,
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
id,
|
||||||
|
oauth2::token::OAuth2AccessToken,
|
||||||
|
raise_error,
|
||||||
|
store::tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
|
||||||
|
users::{payload::UserUpdateRequest, role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel},
|
||||||
|
utc_now,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type AccountModel = Account;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
|
pub enum AccountType {
|
||||||
|
#[default]
|
||||||
|
IMAP,
|
||||||
|
NoSync,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
|
pub enum QuotaWindow {
|
||||||
|
Hourly,
|
||||||
|
#[default]
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct Account {
|
||||||
|
pub id: u64,
|
||||||
|
pub imap: Option<ImapConfig>,
|
||||||
|
pub enabled: bool,
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "web-api",
|
||||||
|
oai(validator(custom = "crate::common::validator::EmailValidator"))
|
||||||
|
)]
|
||||||
|
pub email: String,
|
||||||
|
pub account_name: Option<String>,
|
||||||
|
pub login_name: Option<String>,
|
||||||
|
pub capabilities: Option<Vec<String>>,
|
||||||
|
pub date_since: Option<DateSince>,
|
||||||
|
pub date_before: Option<RelativeDate>,
|
||||||
|
pub folder_limit: Option<u32>,
|
||||||
|
pub download_folders: Option<Vec<String>>,
|
||||||
|
pub account_type: AccountType,
|
||||||
|
pub download_interval_min: Option<i64>,
|
||||||
|
pub download_batch_size: Option<u32>,
|
||||||
|
pub known_folders: Option<BTreeSet<String>>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub created_by: u64, //user id
|
||||||
|
pub use_proxy: Option<u64>,
|
||||||
|
pub use_dangerous: bool,
|
||||||
|
pub pgp_key: Option<String>,
|
||||||
|
pub imap_quota_bytes: Option<u64>,
|
||||||
|
pub imap_quota_window: Option<QuotaWindow>,
|
||||||
|
pub auto_download_new_mailboxes: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemDbModel for Account {
|
||||||
|
fn collection() -> &'static str {
|
||||||
|
"accounts"
|
||||||
|
}
|
||||||
|
fn key(&self) -> String {
|
||||||
|
self.id.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
id: id!(64),
|
||||||
|
email: request.email,
|
||||||
|
login_name: request.login_name,
|
||||||
|
account_name: request.account_name,
|
||||||
|
imap: request.imap.map(|i| i.try_encrypt_password()).transpose()?,
|
||||||
|
enabled: request.enabled,
|
||||||
|
capabilities: None,
|
||||||
|
date_since: request.date_since,
|
||||||
|
download_folders: None,
|
||||||
|
known_folders: None,
|
||||||
|
account_type: request.account_type,
|
||||||
|
download_interval_min: request.download_interval_min,
|
||||||
|
created_at: utc_now!(),
|
||||||
|
updated_at: utc_now!(),
|
||||||
|
use_proxy: request.use_proxy,
|
||||||
|
folder_limit: request.folder_limit,
|
||||||
|
use_dangerous: request.use_dangerous,
|
||||||
|
pgp_key: request.pgp_key,
|
||||||
|
created_by: user_id,
|
||||||
|
download_batch_size: request.download_batch_size,
|
||||||
|
date_before: request.date_before,
|
||||||
|
auto_download_new_mailboxes: request.auto_download_new_mailboxes,
|
||||||
|
imap_quota_bytes: request.imap_quota_bytes,
|
||||||
|
imap_quota_window: request.imap_quota_window,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
|
||||||
|
Self::get(account_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(account_id: u64) -> BichonResult<AccountModel> {
|
||||||
|
let result: AccountModel = Self::find(account_id)?.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Account with ID '{account_id}' not found"),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
|
||||||
|
let result = find_impl::<AccountModel>(DB_MANAGER.db(), &account_id.to_string())?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_account(
|
||||||
|
user_id: u64,
|
||||||
|
request: AccountCreateRequest,
|
||||||
|
) -> BichonResult<AccountModel> {
|
||||||
|
let entity = request.create_entity(user_id)?;
|
||||||
|
let cloned = entity.clone();
|
||||||
|
|
||||||
|
// Insert account into memdb
|
||||||
|
insert_impl(DB_MANAGER.db(), entity)?;
|
||||||
|
|
||||||
|
// Update user's account_access_map
|
||||||
|
let user = UserModel::find(user_id)?.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("User with id={} not found.", user_id),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut updated_map = user.account_access_map.clone();
|
||||||
|
updated_map.insert(cloned.id, DEFAULT_ACCOUNT_MANAGER_ROLE_ID);
|
||||||
|
|
||||||
|
UserModel::update(
|
||||||
|
user_id,
|
||||||
|
UserUpdateRequest {
|
||||||
|
username: None,
|
||||||
|
email: None,
|
||||||
|
password: None,
|
||||||
|
avatar_base64: None,
|
||||||
|
global_roles: None,
|
||||||
|
account_access_map: Some(updated_map),
|
||||||
|
acl: None,
|
||||||
|
description: None,
|
||||||
|
theme: None,
|
||||||
|
language: None,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if matches!(cloned.account_type, AccountType::IMAP) {
|
||||||
|
DOWNLOAD_CONTROLLER
|
||||||
|
.trigger_schedule(cloned.id, cloned.email.clone())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(cloned)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(
|
||||||
|
account_id: u64,
|
||||||
|
request: AccountUpdateRequest,
|
||||||
|
validate: bool,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
let account = AccountModel::get(account_id)?;
|
||||||
|
if validate {
|
||||||
|
request.validate_update_request(&account)?;
|
||||||
|
}
|
||||||
|
update_impl(
|
||||||
|
DB_MANAGER.db(),
|
||||||
|
&account_id.to_string(),
|
||||||
|
move |current: Account| Self::apply_update_fields(¤t, request),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(account_id: u64) -> BichonResult<()> {
|
||||||
|
let account = Self::get(account_id)?;
|
||||||
|
if let Err(error) = Self::cleanup_account_resources_sequential(&account).await {
|
||||||
|
tracing::error!(
|
||||||
|
"[CLEANUP_ACCOUNT_ERROR] Account {}: failed to cleanup resources: {:#?}",
|
||||||
|
account_id,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_account(account: &AccountModel) -> BichonResult<()> {
|
||||||
|
delete_impl::<AccountModel>(DB_MANAGER.db(), &account.id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_account_resources_sequential(account: &AccountModel) -> BichonResult<()> {
|
||||||
|
if matches!(account.account_type, AccountType::IMAP) {
|
||||||
|
SYNC_TASKS.stop(account.id).await?;
|
||||||
|
DownloadState::delete(account.id)?;
|
||||||
|
}
|
||||||
|
OAuth2AccessToken::try_delete(account.id)?;
|
||||||
|
UserModel::cleanup_account(account.id)?;
|
||||||
|
MailBox::clean(account.id)?;
|
||||||
|
ENVELOPE_MANAGER
|
||||||
|
.delete_account_envelopes(account.id)
|
||||||
|
.await?;
|
||||||
|
ATTACHMENT_MANAGER
|
||||||
|
.delete_account_attachments(account.id)
|
||||||
|
.await?;
|
||||||
|
Self::delete_account(account)?;
|
||||||
|
info!("Sequential cleanup completed for account: {}", account.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_download_folders(
|
||||||
|
account_id: u64,
|
||||||
|
download_folders: Vec<String>,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
update_impl(
|
||||||
|
DB_MANAGER.db(),
|
||||||
|
&account_id.to_string(),
|
||||||
|
move |current: Account| {
|
||||||
|
let mut updated = current.clone();
|
||||||
|
updated.download_folders = Some(download_folders);
|
||||||
|
Ok(updated)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_known_folders(
|
||||||
|
account_id: u64,
|
||||||
|
known_folders: BTreeSet<String>,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
update_impl(
|
||||||
|
DB_MANAGER.db(),
|
||||||
|
&account_id.to_string(),
|
||||||
|
move |current: Account| {
|
||||||
|
let mut updated = current.clone();
|
||||||
|
updated.known_folders = Some(known_folders);
|
||||||
|
Ok(updated)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_capabilities(account_id: u64, capabilities: Vec<String>) -> BichonResult<()> {
|
||||||
|
update_impl(
|
||||||
|
DB_MANAGER.db(),
|
||||||
|
&account_id.to_string(),
|
||||||
|
move |current: Account| {
|
||||||
|
let mut updated = current.clone();
|
||||||
|
updated.capabilities = Some(capabilities);
|
||||||
|
Ok(updated)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a list of all `AccountEntity` instances.
|
||||||
|
pub fn list_all() -> BichonResult<Vec<AccountModel>> {
|
||||||
|
list_all_impl::<AccountModel>(DB_MANAGER.db())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_email(email: &str) -> BichonResult<Option<AccountModel>> {
|
||||||
|
let all: Vec<AccountModel> = list_all_impl::<AccountModel>(DB_MANAGER.db())?;
|
||||||
|
let target_email = email.trim().to_lowercase();
|
||||||
|
|
||||||
|
let first_match = all
|
||||||
|
.into_iter()
|
||||||
|
.find(|acc| acc.email.to_lowercase() == target_email);
|
||||||
|
|
||||||
|
Ok(first_match)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
|
||||||
|
let result = list_all_impl::<AccountModel>(DB_MANAGER.db())?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|account: &AccountModel| {
|
||||||
|
!only_nosync || matches!(account.account_type, AccountType::NoSync)
|
||||||
|
})
|
||||||
|
.map(|account: AccountModel| MinimalAccount {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
})
|
||||||
|
.collect::<Vec<MinimalAccount>>();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count() -> BichonResult<usize> {
|
||||||
|
count_impl::<AccountModel>(DB_MANAGER.db())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paginate_list(
|
||||||
|
page: Option<u64>,
|
||||||
|
page_size: Option<u64>,
|
||||||
|
desc: Option<bool>,
|
||||||
|
) -> BichonResult<DataPage<AccountModel>> {
|
||||||
|
paginate_impl::<AccountModel>(DB_MANAGER.db(), page, page_size, desc).map(DataPage::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method applies the updates from the request to the old account entity
|
||||||
|
fn apply_update_fields(
|
||||||
|
old: &AccountModel,
|
||||||
|
request: AccountUpdateRequest,
|
||||||
|
) -> BichonResult<AccountModel> {
|
||||||
|
let mut new = old.clone();
|
||||||
|
|
||||||
|
if let Some(date_since) = request.date_since {
|
||||||
|
new.date_since = Some(date_since);
|
||||||
|
new.date_before = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(date_before) = request.date_before {
|
||||||
|
new.date_before = Some(date_before);
|
||||||
|
new.date_since = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(clear_date_range) = request.clear_date_range {
|
||||||
|
if clear_date_range {
|
||||||
|
new.date_since = None;
|
||||||
|
new.date_before = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(folder_limit) = request.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 matches!(old.account_type, AccountType::IMAP) {
|
||||||
|
if let Some(imap) = &request.imap {
|
||||||
|
if let Some(current_imap) = &mut new.imap {
|
||||||
|
current_imap.host = imap.host.clone();
|
||||||
|
current_imap.port = imap.port.clone();
|
||||||
|
current_imap.encryption = imap.encryption.clone();
|
||||||
|
current_imap.auth.auth_type = imap.auth.auth_type.clone();
|
||||||
|
if let Some(password) = &imap.auth.password {
|
||||||
|
let encrypted_password = encrypt!(password)?;
|
||||||
|
current_imap.auth.password = Some(encrypted_password);
|
||||||
|
}
|
||||||
|
current_imap.use_proxy = imap.use_proxy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(folder_names) = request.sync_folders {
|
||||||
|
new.download_folders = Some(folder_names);
|
||||||
|
}
|
||||||
|
if let Some(sync_interval_min) = &request.download_interval_min {
|
||||||
|
new.download_interval_min = Some(*sync_interval_min);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(download_batch_size) = &request.download_batch_size {
|
||||||
|
new.download_batch_size = Some(*download_batch_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(use_proxy) = request.use_proxy {
|
||||||
|
new.use_proxy = Some(use_proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(old.account_type, AccountType::NoSync) {
|
||||||
|
if let Some(email) = &request.email {
|
||||||
|
new.email = email.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(enabled) = request.enabled {
|
||||||
|
new.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(use_dangerous) = request.use_dangerous {
|
||||||
|
new.use_dangerous = use_dangerous;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pgp_key) = request.pgp_key {
|
||||||
|
new.pgp_key = Some(pgp_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(imap_quota_bytes) = request.imap_quota_bytes {
|
||||||
|
new.imap_quota_bytes = Some(imap_quota_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(imap_quota_window) = request.imap_quota_window {
|
||||||
|
new.imap_quota_window = Some(imap_quota_window);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(auto_download_new_mailboxes) = request.auto_download_new_mailboxes {
|
||||||
|
new.auto_download_new_mailboxes = Some(auto_download_new_mailboxes);
|
||||||
|
}
|
||||||
|
new.updated_at = utc_now!();
|
||||||
|
Ok(new)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,11 +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/>.
|
||||||
|
|
||||||
pub mod dispatcher;
|
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod grant;
|
pub mod grant;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
|
pub mod old_state;
|
||||||
pub mod payload;
|
pub mod payload;
|
||||||
pub mod since;
|
pub mod since;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod stats;
|
||||||
pub mod view;
|
pub mod view;
|
||||||
245
crates/core/src/account/old_state.rs
Normal file
245
crates/core/src/account/old_state.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct MailboxBatchProgress {
|
||||||
|
pub total_batches: u32,
|
||||||
|
pub current_batch: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct AccountRunningState {
|
||||||
|
pub account_id: u64,
|
||||||
|
pub last_incremental_sync_start: i64,
|
||||||
|
pub last_incremental_sync_end: Option<i64>,
|
||||||
|
pub errors: Vec<AccountError>,
|
||||||
|
pub is_initial_sync_completed: bool,
|
||||||
|
pub progress: Option<BTreeMap<String, MailboxBatchProgress>>,
|
||||||
|
pub initial_sync_start_time: Option<i64>,
|
||||||
|
pub initial_sync_end_time: Option<i64>,
|
||||||
|
pub initial_sync_failed_time: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct AccountError {
|
||||||
|
pub error: String,
|
||||||
|
pub at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// impl AccountRunningState {
|
||||||
|
// pub async fn add(account_id: u64) -> BichonResult<()> {
|
||||||
|
// let info = AccountRunningState {
|
||||||
|
// account_id,
|
||||||
|
// last_incremental_sync_start: 0,
|
||||||
|
// last_incremental_sync_end: None,
|
||||||
|
// errors: vec![],
|
||||||
|
// is_initial_sync_completed: false,
|
||||||
|
// progress: None,
|
||||||
|
// initial_sync_start_time: Some(utc_now!()),
|
||||||
|
// initial_sync_end_time: None,
|
||||||
|
// initial_sync_failed_time: None,
|
||||||
|
// };
|
||||||
|
// upsert_impl(DB_MANAGER.envelope_db(), info).await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn get(account_id: u64) -> BichonResult<Option<AccountRunningState>> {
|
||||||
|
// async_find_impl(DB_MANAGER.envelope_db(), account_id).await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async fn update_account_running_state(
|
||||||
|
// account_id: u64,
|
||||||
|
// updater: impl FnOnce(&AccountRunningState) -> BichonResult<AccountRunningState> + Send + 'static,
|
||||||
|
// ) -> BichonResult<()> {
|
||||||
|
// if Self::get(account_id).await?.is_some() {
|
||||||
|
// update_impl(
|
||||||
|
// DB_MANAGER.envelope_db(),
|
||||||
|
// move |rw| {
|
||||||
|
// rw.get()
|
||||||
|
// .primary::<AccountRunningState>(account_id)
|
||||||
|
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||||
|
// .ok_or_else(|| {
|
||||||
|
// raise_error!(
|
||||||
|
// format!("Cannot find sync info of account={}", account_id),
|
||||||
|
// ErrorCode::ResourceNotFound
|
||||||
|
// )
|
||||||
|
// })
|
||||||
|
// },
|
||||||
|
// updater,
|
||||||
|
// )
|
||||||
|
// .await?;
|
||||||
|
// }
|
||||||
|
// Ok(())
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn delete(account_id: u64) -> BichonResult<()> {
|
||||||
|
// if Self::get(account_id).await?.is_none() {
|
||||||
|
// return Ok(());
|
||||||
|
// }
|
||||||
|
|
||||||
|
// delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
||||||
|
// rw.get()
|
||||||
|
// .primary::<AccountRunningState>(account_id)
|
||||||
|
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||||
|
// .ok_or_else(|| {
|
||||||
|
// raise_error!(
|
||||||
|
// format!(
|
||||||
|
// "AccountRunningState '{}' not found during deletion process.",
|
||||||
|
// account_id
|
||||||
|
// ),
|
||||||
|
// ErrorCode::ResourceNotFound
|
||||||
|
// )
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
// .await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
|
||||||
|
// // Self::update_account_running_state(account_id, move |current| {
|
||||||
|
// // let mut updated = current.clone();
|
||||||
|
// // updated.initial_sync_start_time = Some(utc_now!());
|
||||||
|
// // Ok(updated)
|
||||||
|
// // })
|
||||||
|
// // .await
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
|
||||||
|
// Self::update_account_running_state(account_id, move |current| {
|
||||||
|
// let mut updated = current.clone();
|
||||||
|
// updated.is_initial_sync_completed = true;
|
||||||
|
// updated.initial_sync_end_time = Some(utc_now!());
|
||||||
|
// Ok(updated)
|
||||||
|
// })
|
||||||
|
// .await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn set_initial_sync_failed(account_id: u64) -> BichonResult<()> {
|
||||||
|
// Self::update_account_running_state(account_id, move |current| {
|
||||||
|
// let mut updated = current.clone();
|
||||||
|
// updated.initial_sync_failed_time = Some(utc_now!());
|
||||||
|
// Ok(updated)
|
||||||
|
// })
|
||||||
|
// .await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn set_current_sync_batch_number(
|
||||||
|
// account_id: u64,
|
||||||
|
// syncing_folder: String,
|
||||||
|
// batch_number: u32,
|
||||||
|
// ) -> BichonResult<()> {
|
||||||
|
// Self::update_account_running_state(account_id, move |current| {
|
||||||
|
// let mut updated = current.clone();
|
||||||
|
// let mut progress_map = updated.progress.clone().unwrap_or_default();
|
||||||
|
// let entry =
|
||||||
|
// progress_map
|
||||||
|
// .entry(syncing_folder.to_string())
|
||||||
|
// .or_insert(MailboxBatchProgress {
|
||||||
|
// total_batches: 0,
|
||||||
|
// current_batch: 0,
|
||||||
|
// });
|
||||||
|
// entry.current_batch = batch_number;
|
||||||
|
// updated.progress = Some(progress_map);
|
||||||
|
// Ok(updated)
|
||||||
|
// })
|
||||||
|
// .await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn set_folder_initial_sync_completed(
|
||||||
|
// account_id: u64,
|
||||||
|
// syncing_folder: String,
|
||||||
|
// ) -> BichonResult<()> {
|
||||||
|
// Self::update_account_running_state(account_id, move |current| {
|
||||||
|
// let mut updated = current.clone();
|
||||||
|
// let mut progress_map = updated.progress.clone().unwrap_or_default();
|
||||||
|
// let entry =
|
||||||
|
// progress_map
|
||||||
|
// .entry(syncing_folder.to_string())
|
||||||
|
// .or_insert(MailboxBatchProgress {
|
||||||
|
// total_batches: 0,
|
||||||
|
// current_batch: 0,
|
||||||
|
// });
|
||||||
|
// entry.current_batch = entry.total_batches;
|
||||||
|
// updated.progress = Some(progress_map);
|
||||||
|
// Ok(updated)
|
||||||
|
// })
|
||||||
|
// .await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn set_initial_current_syncing_folder(
|
||||||
|
// account_id: u64,
|
||||||
|
// current_syncing_folder: String,
|
||||||
|
// total_sync_batches: u32,
|
||||||
|
// ) -> BichonResult<()> {
|
||||||
|
// Self::update_account_running_state(account_id, move |current| {
|
||||||
|
// let mut updated = current.clone();
|
||||||
|
// let mut progress_map = updated.progress.clone().unwrap_or_default();
|
||||||
|
// progress_map.insert(
|
||||||
|
// current_syncing_folder.clone(),
|
||||||
|
// MailboxBatchProgress {
|
||||||
|
// total_batches: total_sync_batches,
|
||||||
|
// current_batch: 0,
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// updated.progress = Some(progress_map);
|
||||||
|
// Ok(updated)
|
||||||
|
// })
|
||||||
|
// .await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn set_incremental_sync_start(account_id: u64) -> BichonResult<()> {
|
||||||
|
// Self::update_account_running_state(account_id, move |current| {
|
||||||
|
// let mut updated = current.clone();
|
||||||
|
// updated.last_incremental_sync_start = utc_now!();
|
||||||
|
// updated.last_incremental_sync_end = None;
|
||||||
|
// Ok(updated)
|
||||||
|
// })
|
||||||
|
// .await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn set_incremental_sync_end(account_id: u64) -> BichonResult<()> {
|
||||||
|
// Self::update_account_running_state(account_id, move |current| {
|
||||||
|
// let mut updated = current.clone();
|
||||||
|
// updated.last_incremental_sync_end = Some(utc_now!());
|
||||||
|
// Ok(updated)
|
||||||
|
// })
|
||||||
|
// .await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub async fn append_error_message(account_id: u64, error: String) -> BichonResult<()> {
|
||||||
|
// Self::update_account_running_state(account_id, move |current| {
|
||||||
|
// let mut updated = current.clone();
|
||||||
|
// updated.append_error_log(error);
|
||||||
|
// Ok(updated)
|
||||||
|
// })
|
||||||
|
// .await
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub fn append_error_log(&mut self, error: String) {
|
||||||
|
// let new_error = AccountError {
|
||||||
|
// error,
|
||||||
|
// at: utc_now!(),
|
||||||
|
// };
|
||||||
|
|
||||||
|
// self.errors.push(new_error);
|
||||||
|
// if self.errors.len() > ERROR_COUNT_PER_ACCOUNT {
|
||||||
|
// self.errors.remove(0);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,34 +16,44 @@
|
|||||||
// 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::account::entity::ImapConfig;
|
use crate::account::entity::ImapConfig;
|
||||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
use crate::account::migration::{AccountModel, AccountType, QuotaWindow};
|
||||||
use crate::modules::account::since::{DateSince, RelativeDate};
|
use crate::account::since::{DateSince, RelativeDate};
|
||||||
use crate::modules::error::code::ErrorCode;
|
use crate::error::code::ErrorCode;
|
||||||
use crate::modules::error::BichonResult;
|
use crate::error::BichonResult;
|
||||||
use crate::{raise_error, validate_email};
|
use crate::{raise_error, validate_email};
|
||||||
use poem_openapi::Object;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct AccountCreateRequest {
|
pub struct AccountCreateRequest {
|
||||||
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
|
#[cfg_attr(
|
||||||
|
feature = "web-api",
|
||||||
|
oai(validator(custom = "crate::common::validator::EmailValidator"))
|
||||||
|
)]
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub name: Option<String>,
|
pub login_name: Option<String>,
|
||||||
|
pub account_name: Option<String>,
|
||||||
pub imap: Option<ImapConfig>,
|
pub imap: Option<ImapConfig>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub date_since: Option<DateSince>,
|
pub date_since: Option<DateSince>,
|
||||||
pub date_before: Option<RelativeDate>,
|
pub date_before: Option<RelativeDate>,
|
||||||
pub account_type: AccountType,
|
pub account_type: AccountType,
|
||||||
#[oai(validator(minimum(value = "100")))]
|
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "100"))))]
|
||||||
pub folder_limit: Option<u32>,
|
pub folder_limit: Option<u32>,
|
||||||
#[oai(validator(minimum(value = "10")))]
|
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
|
||||||
pub sync_interval_min: Option<i64>,
|
pub download_interval_min: Option<i64>,
|
||||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
#[cfg_attr(
|
||||||
pub sync_batch_size: Option<u32>,
|
feature = "web-api",
|
||||||
|
oai(validator(minimum(value = "10"), maximum(value = "200")))
|
||||||
|
)]
|
||||||
|
pub download_batch_size: Option<u32>,
|
||||||
pub use_proxy: Option<u64>,
|
pub use_proxy: Option<u64>,
|
||||||
pub use_dangerous: bool,
|
pub use_dangerous: bool,
|
||||||
pub pgp_key: Option<String>,
|
pub pgp_key: Option<String>,
|
||||||
|
pub imap_quota_bytes: Option<u64>,
|
||||||
|
pub imap_quota_window: Option<QuotaWindow>,
|
||||||
|
pub auto_download_new_mailboxes: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AccountCreateRequest {
|
impl AccountCreateRequest {
|
||||||
@@ -56,6 +66,13 @@ impl AccountCreateRequest {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.imap_quota_bytes.is_some() ^ self.imap_quota_window.is_some() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Quota bytes and quota window must be provided together or omitted together".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(date_since) = self.date_since.as_ref() {
|
if let Some(date_since) = self.date_since.as_ref() {
|
||||||
date_since.validate()?;
|
date_since.validate()?;
|
||||||
}
|
}
|
||||||
@@ -75,7 +92,7 @@ impl AccountCreateRequest {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.sync_interval_min.is_none() {
|
if self.download_interval_min.is_none() {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
"`sync_interval_min` is required for IMAP account type".into(),
|
"`sync_interval_min` is required for IMAP account type".into(),
|
||||||
ErrorCode::InvalidParameter
|
ErrorCode::InvalidParameter
|
||||||
@@ -96,7 +113,8 @@ impl AccountCreateRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct AccountUpdateRequest {
|
pub struct AccountUpdateRequest {
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
/// Represents the account activation status.
|
/// Represents the account activation status.
|
||||||
@@ -105,8 +123,7 @@ pub struct AccountUpdateRequest {
|
|||||||
/// and any attempts to access them should return an error indicating the account
|
/// and any attempts to access them should return an error indicating the account
|
||||||
/// is inactive.
|
/// is inactive.
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
/// Display name for the account (optional)
|
pub account_name: Option<String>,
|
||||||
pub name: Option<String>,
|
|
||||||
/// IMAP server configuration
|
/// IMAP server configuration
|
||||||
pub imap: Option<ImapConfig>,
|
pub imap: Option<ImapConfig>,
|
||||||
/// Controls initial synchronization time range
|
/// Controls initial synchronization time range
|
||||||
@@ -125,7 +142,7 @@ pub struct AccountUpdateRequest {
|
|||||||
/// Max emails to sync for this folder.
|
/// Max emails to sync for this folder.
|
||||||
/// If not set, sync all emails.
|
/// If not set, sync all emails.
|
||||||
/// 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")))]
|
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "100"))))]
|
||||||
pub folder_limit: Option<u32>,
|
pub folder_limit: Option<u32>,
|
||||||
pub clear_folder_limit: Option<bool>,
|
pub clear_folder_limit: Option<bool>,
|
||||||
/// Configuration for selective folder (mailbox/label) synchronization
|
/// Configuration for selective folder (mailbox/label) synchronization
|
||||||
@@ -142,11 +159,14 @@ pub struct AccountUpdateRequest {
|
|||||||
/// Defaults to standard folders (`INBOX`, `Sent`) if empty.
|
/// Defaults to standard folders (`INBOX`, `Sent`) if empty.
|
||||||
/// 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 download interval (seconds)
|
||||||
#[oai(validator(minimum(value = "10")))]
|
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
|
||||||
pub sync_interval_min: Option<i64>,
|
pub download_interval_min: Option<i64>,
|
||||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
#[cfg_attr(
|
||||||
pub sync_batch_size: Option<u32>,
|
feature = "web-api",
|
||||||
|
oai(validator(minimum(value = "10"), maximum(value = "200")))
|
||||||
|
)]
|
||||||
|
pub download_batch_size: Option<u32>,
|
||||||
/// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook).
|
/// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook).
|
||||||
/// - If `None` or not provided, the client will connect directly to the API server.
|
/// - If `None` or not provided, the client will connect directly to the API server.
|
||||||
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
|
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
|
||||||
@@ -155,6 +175,9 @@ pub struct AccountUpdateRequest {
|
|||||||
pub use_dangerous: Option<bool>,
|
pub use_dangerous: Option<bool>,
|
||||||
|
|
||||||
pub pgp_key: Option<String>,
|
pub pgp_key: Option<String>,
|
||||||
|
pub imap_quota_bytes: Option<u64>,
|
||||||
|
pub imap_quota_window: Option<QuotaWindow>,
|
||||||
|
pub auto_download_new_mailboxes: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AccountUpdateRequest {
|
impl AccountUpdateRequest {
|
||||||
@@ -167,6 +190,13 @@ impl AccountUpdateRequest {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.imap_quota_bytes.is_some() ^ self.imap_quota_window.is_some() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Quota bytes and quota window must be provided together or omitted together".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
|
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
"clear_folder_limit cannot be combined with folder_limit".into(),
|
"clear_folder_limit cannot be combined with folder_limit".into(),
|
||||||
@@ -205,7 +235,8 @@ impl AccountUpdateRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
|
||||||
pub struct MinimalAccount {
|
pub struct MinimalAccount {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -17,14 +17,14 @@
|
|||||||
// 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::{
|
||||||
modules::error::{code::ErrorCode, BichonResult},
|
error::{code::ErrorCode, BichonResult},
|
||||||
raise_error,
|
raise_error,
|
||||||
};
|
};
|
||||||
use chrono::{Datelike, Days, Local, Months, NaiveDate, Utc};
|
use chrono::{Datelike, Days, Local, Months, NaiveDate, Utc};
|
||||||
use poem_openapi::{Enum, Object};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct DateSince {
|
pub struct DateSince {
|
||||||
/// Absolute date boundary in ISO 8601 format (YYYY-MM-DD)
|
/// Absolute date boundary in ISO 8601 format (YYYY-MM-DD)
|
||||||
///
|
///
|
||||||
@@ -38,7 +38,7 @@ pub struct DateSince {
|
|||||||
/// "fixed": "2025-05-01"
|
/// "fixed": "2025-05-01"
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
#[oai(validator(pattern = r"^\d{4}-\d{2}-\d{2}$"))]
|
#[cfg_attr(feature = "web-api", oai(validator(pattern = r"^\d{4}-\d{2}-\d{2}$")))]
|
||||||
pub fixed: Option<String>,
|
pub fixed: Option<String>,
|
||||||
/// Relative time period from current date
|
/// Relative time period from current date
|
||||||
///
|
///
|
||||||
@@ -58,7 +58,8 @@ pub struct DateSince {
|
|||||||
pub relative: Option<RelativeDate>,
|
pub relative: Option<RelativeDate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
pub enum Unit {
|
pub enum Unit {
|
||||||
#[default]
|
#[default]
|
||||||
Days,
|
Days,
|
||||||
@@ -66,12 +67,13 @@ pub enum Unit {
|
|||||||
Years,
|
Years,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct RelativeDate {
|
pub struct RelativeDate {
|
||||||
/// The time unit to use for the offset (days, months, or years)
|
/// The time unit to use for the offset (days, months, or years)
|
||||||
pub unit: Unit,
|
pub unit: Unit,
|
||||||
/// The quantity of time units to offset (must be a positive integer)
|
/// The quantity of time units to offset (must be a positive integer)
|
||||||
#[oai(validator(minimum(value = "1")))]
|
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "1"))))]
|
||||||
pub value: u32,
|
pub value: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,19 +257,47 @@ impl DateSince {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use crate::modules::account::since::{DateSince, RelativeDate, Unit};
|
use crate::account::since::{DateSince, RelativeDate, Unit};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test1() {
|
fn fixed_date_valid() {
|
||||||
let e = DateSince {
|
let e = DateSince {
|
||||||
fixed: Some("2014-09-12".to_string()),
|
fixed: Some("2014-09-12".to_string()),
|
||||||
relative: None,
|
relative: None,
|
||||||
};
|
};
|
||||||
|
assert!(e.validate().is_ok());
|
||||||
|
assert!(!e.since_date().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
e.validate().unwrap();
|
#[test]
|
||||||
|
fn fixed_date_in_future_fails() {
|
||||||
|
let e = DateSince {
|
||||||
|
fixed: Some("2099-01-01".to_string()),
|
||||||
|
relative: None,
|
||||||
|
};
|
||||||
|
assert!(e.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
println!("{}", e.since_date().unwrap());
|
#[test]
|
||||||
|
fn fixed_date_before_1970_fails() {
|
||||||
|
let e = DateSince {
|
||||||
|
fixed: Some("1960-01-01".to_string()),
|
||||||
|
relative: None,
|
||||||
|
};
|
||||||
|
assert!(e.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_date_bad_format_fails() {
|
||||||
|
let e = DateSince {
|
||||||
|
fixed: Some("01-01-2020".to_string()),
|
||||||
|
relative: None,
|
||||||
|
};
|
||||||
|
assert!(e.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_date_days_valid() {
|
||||||
let e = DateSince {
|
let e = DateSince {
|
||||||
fixed: None,
|
fixed: None,
|
||||||
relative: Some(RelativeDate {
|
relative: Some(RelativeDate {
|
||||||
@@ -275,9 +305,63 @@ mod test {
|
|||||||
value: 1,
|
value: 1,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
assert!(e.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
e.validate().unwrap();
|
#[test]
|
||||||
|
fn relative_date_months_valid() {
|
||||||
|
let e = DateSince {
|
||||||
|
fixed: None,
|
||||||
|
relative: Some(RelativeDate {
|
||||||
|
unit: Unit::Months,
|
||||||
|
value: 3,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
assert!(e.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
println!("{}", e.since_date().unwrap());
|
#[test]
|
||||||
|
fn relative_date_years_valid() {
|
||||||
|
let e = DateSince {
|
||||||
|
fixed: None,
|
||||||
|
relative: Some(RelativeDate {
|
||||||
|
unit: Unit::Years,
|
||||||
|
value: 1,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
assert!(e.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_date_zero_value_fails() {
|
||||||
|
let e = DateSince {
|
||||||
|
fixed: None,
|
||||||
|
relative: Some(RelativeDate {
|
||||||
|
unit: Unit::Days,
|
||||||
|
value: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
assert!(e.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_fixed_and_relative_fails() {
|
||||||
|
let e = DateSince {
|
||||||
|
fixed: Some("2014-09-12".to_string()),
|
||||||
|
relative: Some(RelativeDate {
|
||||||
|
unit: Unit::Days,
|
||||||
|
value: 1,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
assert!(e.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neither_fixed_nor_relative_fails() {
|
||||||
|
let e = DateSince {
|
||||||
|
fixed: None,
|
||||||
|
relative: None,
|
||||||
|
};
|
||||||
|
assert!(e.validate().is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
283
crates/core/src/account/state.rs
Normal file
283
crates/core/src/account/state.rs
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
database::{delete_impl, find_impl, manager::DB_MANAGER, update_impl, upsert_impl, MemDbModel},
|
||||||
|
error::BichonResult,
|
||||||
|
utc_now,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
|
pub enum DownloadStatus {
|
||||||
|
Running,
|
||||||
|
Success,
|
||||||
|
Failed,
|
||||||
|
#[default]
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
|
pub enum TriggerType {
|
||||||
|
Manual,
|
||||||
|
#[default]
|
||||||
|
Scheduled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
|
pub enum FolderStatus {
|
||||||
|
#[default]
|
||||||
|
Pending,
|
||||||
|
Downloading,
|
||||||
|
Success,
|
||||||
|
Failed,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct FolderProgress {
|
||||||
|
pub folder_name: String,
|
||||||
|
pub planned: u64,
|
||||||
|
pub current: u64,
|
||||||
|
pub status: FolderStatus,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct DownloadSession {
|
||||||
|
pub start_time: i64,
|
||||||
|
pub end_time: Option<i64>,
|
||||||
|
pub status: DownloadStatus,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub trigger: TriggerType,
|
||||||
|
pub folder_details: BTreeMap<String, FolderProgress>,
|
||||||
|
pub current_folder: Option<String>,
|
||||||
|
pub errors: Vec<AccountError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct DownloadState {
|
||||||
|
pub account_id: u64,
|
||||||
|
pub active_session: Option<DownloadSession>,
|
||||||
|
pub history: Vec<DownloadSession>,
|
||||||
|
pub last_trigger_at: i64,
|
||||||
|
pub last_finished_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemDbModel for DownloadState {
|
||||||
|
fn collection() -> &'static str {
|
||||||
|
"download_states"
|
||||||
|
}
|
||||||
|
fn key(&self) -> String {
|
||||||
|
self.account_id.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct AccountError {
|
||||||
|
pub error: String,
|
||||||
|
pub at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadState {
|
||||||
|
pub fn empty(account_id: u64) -> Self {
|
||||||
|
DownloadState {
|
||||||
|
account_id,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init(account_id: u64) -> BichonResult<()> {
|
||||||
|
let now = utc_now!();
|
||||||
|
let state = DownloadState {
|
||||||
|
account_id,
|
||||||
|
last_trigger_at: now,
|
||||||
|
active_session: Some(DownloadSession {
|
||||||
|
start_time: now,
|
||||||
|
status: DownloadStatus::Running,
|
||||||
|
trigger: TriggerType::Scheduled,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
history: Default::default(),
|
||||||
|
last_finished_at: Default::default(),
|
||||||
|
};
|
||||||
|
upsert_impl(DB_MANAGER.db(), state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(account_id: u64) -> BichonResult<Option<DownloadState>> {
|
||||||
|
find_impl::<DownloadState>(DB_MANAGER.db(), &account_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_new_session(account_id: u64, trigger: TriggerType) -> BichonResult<()> {
|
||||||
|
Self::update_state(account_id, move |current| {
|
||||||
|
let mut updated = current.clone();
|
||||||
|
updated.last_trigger_at = utc_now!();
|
||||||
|
|
||||||
|
if let Some(mut old_session) = updated.active_session.take() {
|
||||||
|
if old_session.status == DownloadStatus::Running {
|
||||||
|
old_session.status = DownloadStatus::Cancelled;
|
||||||
|
old_session.end_time = Some(utc_now!());
|
||||||
|
old_session.message = Some("Interrupted by a new download session.".into());
|
||||||
|
}
|
||||||
|
updated.history.push(old_session);
|
||||||
|
if updated.history.len() > 30 {
|
||||||
|
updated.history.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_session = DownloadSession {
|
||||||
|
start_time: utc_now!(),
|
||||||
|
status: DownloadStatus::Running,
|
||||||
|
trigger,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
updated.active_session = Some(new_session);
|
||||||
|
Ok(updated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_session_status(
|
||||||
|
account_id: u64,
|
||||||
|
status: DownloadStatus,
|
||||||
|
message: Option<String>,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
Self::update_state(account_id, move |current| {
|
||||||
|
let mut updated = current.clone();
|
||||||
|
if let Some(mut session) = updated.active_session.take() {
|
||||||
|
session.status = status.clone();
|
||||||
|
if message.is_some() {
|
||||||
|
session.message = message;
|
||||||
|
}
|
||||||
|
if status == DownloadStatus::Running {
|
||||||
|
updated.active_session = Some(session);
|
||||||
|
} else {
|
||||||
|
let now = utc_now!();
|
||||||
|
session.end_time = Some(now);
|
||||||
|
updated.last_finished_at = Some(now);
|
||||||
|
updated.history.push(session);
|
||||||
|
let to_remove = updated.history.len().saturating_sub(10);
|
||||||
|
if to_remove > 0 {
|
||||||
|
updated.history.drain(0..to_remove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(updated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_folder_progress(
|
||||||
|
account_id: u64,
|
||||||
|
folder_name: String,
|
||||||
|
planned: u64,
|
||||||
|
current: u64,
|
||||||
|
status: FolderStatus,
|
||||||
|
message: Option<String>,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
Self::update_state(account_id, move |state| {
|
||||||
|
let mut updated = state.clone();
|
||||||
|
if let Some(ref mut session) = updated.active_session {
|
||||||
|
session.current_folder = Some(folder_name.clone());
|
||||||
|
|
||||||
|
let progress =
|
||||||
|
session
|
||||||
|
.folder_details
|
||||||
|
.entry(folder_name.clone())
|
||||||
|
.or_insert(FolderProgress {
|
||||||
|
folder_name,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
progress.planned = planned;
|
||||||
|
progress.current = current;
|
||||||
|
progress.status = status;
|
||||||
|
progress.message = message;
|
||||||
|
}
|
||||||
|
Ok(updated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_folder_details(account_id: u64, folders: Vec<String>) -> BichonResult<()> {
|
||||||
|
Self::update_state(account_id, move |state| {
|
||||||
|
let mut updated = state.clone();
|
||||||
|
if let Some(ref mut session) = updated.active_session {
|
||||||
|
for name in folders {
|
||||||
|
session.folder_details.insert(
|
||||||
|
name.clone(),
|
||||||
|
FolderProgress {
|
||||||
|
folder_name: name,
|
||||||
|
planned: 0,
|
||||||
|
current: 0,
|
||||||
|
status: FolderStatus::Pending,
|
||||||
|
message: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(updated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_session_error(account_id: u64, error: String) -> BichonResult<()> {
|
||||||
|
Self::update_state(account_id, move |current| {
|
||||||
|
let mut updated = current.clone();
|
||||||
|
let new_error = AccountError {
|
||||||
|
error,
|
||||||
|
at: utc_now!(),
|
||||||
|
};
|
||||||
|
let target = updated
|
||||||
|
.active_session
|
||||||
|
.as_mut()
|
||||||
|
.or_else(|| updated.history.last_mut());
|
||||||
|
if let Some(session) = target {
|
||||||
|
session.errors.push(new_error);
|
||||||
|
let to_remove = session.errors.len().saturating_sub(30);
|
||||||
|
if to_remove > 0 {
|
||||||
|
session.errors.drain(0..to_remove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(updated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_state(
|
||||||
|
account_id: u64,
|
||||||
|
updater: impl FnOnce(DownloadState) -> BichonResult<DownloadState> + Send + 'static,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
if Self::get(account_id)?.is_some() {
|
||||||
|
update_impl(DB_MANAGER.db(), &account_id.to_string(), updater)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(account_id: u64) -> BichonResult<()> {
|
||||||
|
if Self::get(account_id)?.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_impl::<DownloadState>(DB_MANAGER.db(), &account_id.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,9 +16,11 @@
|
|||||||
// 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 serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[tokio::test]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
async fn test() {
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
let config = autoconfig::from_addr("test@gmail.com").await.unwrap();
|
pub struct AccountStats {
|
||||||
println!("{:#?}", config);
|
pub total_size: u64,
|
||||||
|
pub total_count: u64,
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -17,34 +17,34 @@
|
|||||||
// 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 std::collections::{BTreeSet, HashMap};
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|
||||||
use poem_openapi::Object;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::modules::{
|
use crate::{
|
||||||
account::{
|
account::{
|
||||||
entity::ImapConfig,
|
entity::ImapConfig,
|
||||||
migration::{AccountModel, AccountType},
|
migration::{AccountModel, AccountType, QuotaWindow},
|
||||||
since::{DateSince, RelativeDate},
|
since::{DateSince, RelativeDate},
|
||||||
},
|
},
|
||||||
users::UserModel,
|
users::UserModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct AccountResp {
|
pub struct AccountResp {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub imap: Option<ImapConfig>,
|
pub imap: Option<ImapConfig>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub name: Option<String>,
|
pub account_name: Option<String>,
|
||||||
|
pub login_name: Option<String>,
|
||||||
pub capabilities: Option<Vec<String>>,
|
pub capabilities: Option<Vec<String>>,
|
||||||
pub date_since: Option<DateSince>,
|
pub date_since: Option<DateSince>,
|
||||||
pub date_before: Option<RelativeDate>,
|
pub date_before: Option<RelativeDate>,
|
||||||
pub folder_limit: Option<u32>,
|
pub folder_limit: Option<u32>,
|
||||||
pub sync_folders: Option<Vec<String>>,
|
pub download_folders: Option<Vec<String>>,
|
||||||
pub account_type: AccountType,
|
pub account_type: AccountType,
|
||||||
pub sync_interval_min: Option<i64>,
|
pub download_interval_min: Option<i64>,
|
||||||
pub sync_batch_size: Option<u32>,
|
pub download_batch_size: Option<u32>,
|
||||||
pub known_folders: Option<BTreeSet<String>>,
|
pub known_folders: Option<BTreeSet<String>>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
@@ -54,6 +54,9 @@ pub struct AccountResp {
|
|||||||
pub use_proxy: Option<u64>,
|
pub use_proxy: Option<u64>,
|
||||||
pub use_dangerous: bool,
|
pub use_dangerous: bool,
|
||||||
pub pgp_key: Option<String>,
|
pub pgp_key: Option<String>,
|
||||||
|
pub imap_quota_bytes: Option<u64>,
|
||||||
|
pub imap_quota_window: Option<QuotaWindow>,
|
||||||
|
pub auto_download_new_mailboxes: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AccountResp {
|
impl AccountResp {
|
||||||
@@ -64,15 +67,16 @@ impl AccountResp {
|
|||||||
imap: account.imap,
|
imap: account.imap,
|
||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
email: account.email,
|
email: account.email,
|
||||||
name: account.name,
|
account_name: account.account_name,
|
||||||
|
login_name: account.login_name,
|
||||||
capabilities: account.capabilities,
|
capabilities: account.capabilities,
|
||||||
date_since: account.date_since,
|
date_since: account.date_since,
|
||||||
date_before: account.date_before,
|
date_before: account.date_before,
|
||||||
folder_limit: account.folder_limit,
|
folder_limit: account.folder_limit,
|
||||||
sync_folders: account.sync_folders,
|
download_folders: account.download_folders,
|
||||||
account_type: account.account_type,
|
account_type: account.account_type,
|
||||||
sync_interval_min: account.sync_interval_min,
|
download_interval_min: account.download_interval_min,
|
||||||
sync_batch_size: account.sync_batch_size,
|
download_batch_size: account.download_batch_size,
|
||||||
known_folders: account.known_folders,
|
known_folders: account.known_folders,
|
||||||
created_at: account.created_at,
|
created_at: account.created_at,
|
||||||
updated_at: account.updated_at,
|
updated_at: account.updated_at,
|
||||||
@@ -86,6 +90,9 @@ impl AccountResp {
|
|||||||
use_proxy: account.use_proxy,
|
use_proxy: account.use_proxy,
|
||||||
use_dangerous: account.use_dangerous,
|
use_dangerous: account.use_dangerous,
|
||||||
pgp_key: account.pgp_key,
|
pgp_key: account.pgp_key,
|
||||||
|
imap_quota_bytes: account.imap_quota_bytes,
|
||||||
|
imap_quota_window: account.imap_quota_window,
|
||||||
|
auto_download_new_mailboxes: account.auto_download_new_mailboxes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
68
crates/core/src/admin/meta.rs
Normal file
68
crates/core/src/admin/meta.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use memdb::{Durability, MemDb};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
database::MemDbModel,
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
raise_error,
|
||||||
|
users::{UserModel, DEFAULT_ADMIN_USER_ID},
|
||||||
|
utils::encrypt::internal_encrypt_string,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn open_database(path: impl AsRef<Path>) -> BichonResult<MemDb> {
|
||||||
|
MemDb::open_with(path, Durability::Full).map_err(|e| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Failed to open database: {:?}", e),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_admin(db: &MemDb) -> BichonResult<Option<UserModel>> {
|
||||||
|
let key = DEFAULT_ADMIN_USER_ID.to_string();
|
||||||
|
let coll = db.collection(UserModel::collection());
|
||||||
|
coll.get(&key)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_admin_password(
|
||||||
|
db: &MemDb,
|
||||||
|
password: String,
|
||||||
|
encrypt_key: &str,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
let key = DEFAULT_ADMIN_USER_ID.to_string();
|
||||||
|
let coll = db.collection(UserModel::collection());
|
||||||
|
let entity: UserModel = coll
|
||||||
|
.get_required(&key)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
let mut updated = entity.clone();
|
||||||
|
updated.password = Some(
|
||||||
|
internal_encrypt_string(encrypt_key, &password)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
coll.upsert(&key, &updated)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
20
crates/core/src/admin/mod.rs
Normal file
20
crates/core/src/admin/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
pub mod meta;
|
||||||
255
crates/core/src/autoconfig/client.rs
Normal file
255
crates/core/src/autoconfig/client.rs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use hickory_resolver::name_server::TokioConnectionProvider;
|
||||||
|
use hickory_resolver::proto::rr::RData;
|
||||||
|
use hickory_resolver::proto::rr::RecordType;
|
||||||
|
use hickory_resolver::TokioResolver;
|
||||||
|
use quick_xml::de::from_str;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::error::code::ErrorCode;
|
||||||
|
use crate::error::BichonResult;
|
||||||
|
use crate::raise_error;
|
||||||
|
|
||||||
|
/// Parsed result from Thunderbird-style autoconfig XML or DNS SRV fallback.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct MailConfig {
|
||||||
|
pub incoming: Vec<IncomingServer>,
|
||||||
|
pub outgoing: Vec<OutgoingServer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
|
pub struct IncomingServer {
|
||||||
|
#[serde(rename = "@type")]
|
||||||
|
pub protocol: String,
|
||||||
|
pub hostname: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(rename = "socketType")]
|
||||||
|
pub socket_type: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
|
pub struct OutgoingServer {
|
||||||
|
#[serde(rename = "@type")]
|
||||||
|
pub protocol: String,
|
||||||
|
pub hostname: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(rename = "socketType")]
|
||||||
|
pub socket_type: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal XML wrapper structs matching the Thunderbird config-v1.1 schema:
|
||||||
|
// <clientConfig> → <emailProvider> → <incomingServer> / <outgoingServer>
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename = "clientConfig")]
|
||||||
|
struct ClientConfig {
|
||||||
|
#[serde(rename = "emailProvider", default)]
|
||||||
|
email_providers: Vec<EmailProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct EmailProvider {
|
||||||
|
#[serde(rename = "incomingServer", default)]
|
||||||
|
incoming_servers: Vec<IncomingServer>,
|
||||||
|
#[serde(rename = "outgoingServer", default)]
|
||||||
|
outgoing_servers: Vec<OutgoingServer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Thunderbird autoconfig XML into a `MailConfig`.
|
||||||
|
/// Exposed for unit testing.
|
||||||
|
pub(crate) fn parse_autoconfig_xml(xml: &str) -> Option<MailConfig> {
|
||||||
|
let client_config: ClientConfig = from_str(xml).ok()?;
|
||||||
|
let provider = client_config.email_providers.into_iter().next()?;
|
||||||
|
Some(MailConfig {
|
||||||
|
incoming: provider.incoming_servers,
|
||||||
|
outgoing: provider.outgoing_servers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Network helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn fetch_xml(client: &Client, url: &str) -> Option<MailConfig> {
|
||||||
|
let resp = client.get(url).send().await.ok()?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = resp.text().await.ok()?;
|
||||||
|
parse_autoconfig_xml(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lookup_srv(domain: &str) -> Option<MailConfig> {
|
||||||
|
let resolver = TokioResolver::builder(TokioConnectionProvider::default())
|
||||||
|
.ok()?
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let imap_srv = format!("_imaps._tcp.{}.", domain);
|
||||||
|
let imap_lookup = resolver.lookup(imap_srv, RecordType::SRV).await.ok()?;
|
||||||
|
let imap_record = imap_lookup.iter().next()?;
|
||||||
|
let (imap_host, imap_port) = match imap_record {
|
||||||
|
RData::SRV(srv) => {
|
||||||
|
let host = srv.target().to_string().trim_end_matches('.').to_string();
|
||||||
|
(host, srv.port())
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let smtp_srv = format!("_submission._tcp.{}.", domain);
|
||||||
|
let smtp_lookup = resolver.lookup(smtp_srv, RecordType::SRV).await.ok()?;
|
||||||
|
let smtp_record = smtp_lookup.iter().next()?;
|
||||||
|
let (smtp_host, smtp_port) = match smtp_record {
|
||||||
|
RData::SRV(srv) => {
|
||||||
|
let host = srv.target().to_string().trim_end_matches('.').to_string();
|
||||||
|
(host, srv.port())
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(MailConfig {
|
||||||
|
incoming: vec![IncomingServer {
|
||||||
|
protocol: "imap".to_string(),
|
||||||
|
hostname: imap_host,
|
||||||
|
port: imap_port,
|
||||||
|
socket_type: "SSL".to_string(),
|
||||||
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
}],
|
||||||
|
outgoing: vec![OutgoingServer {
|
||||||
|
protocol: "smtp".to_string(),
|
||||||
|
hostname: smtp_host,
|
||||||
|
port: smtp_port,
|
||||||
|
socket_type: "STARTTLS".to_string(),
|
||||||
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Discover mail server configuration for a domain using the Thunderbird
|
||||||
|
/// autoconfig protocol (ISPDB) and DNS SRV fallback.
|
||||||
|
///
|
||||||
|
/// Probe order:
|
||||||
|
/// 1. `https://autoconfig.{domain}/mail/config-v1.1.xml`
|
||||||
|
/// 2. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
|
||||||
|
/// 3. DNS SRV records (`_imaps._tcp` / `_submission._tcp`)
|
||||||
|
/// 4. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`)
|
||||||
|
pub async fn fetch(domain: &str) -> BichonResult<MailConfig> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
// 1. Try autoconfig subdomain
|
||||||
|
if let Some(config) = fetch_xml(
|
||||||
|
&client,
|
||||||
|
&format!("https://autoconfig.{domain}/mail/config-v1.1.xml"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try well-known path
|
||||||
|
if let Some(config) = fetch_xml(
|
||||||
|
&client,
|
||||||
|
&format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try DNS SRV records
|
||||||
|
if let Some(config) = lookup_srv(domain).await {
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fall back to Thunderbird central database
|
||||||
|
if let Some(config) = fetch_xml(
|
||||||
|
&client,
|
||||||
|
&format!("https://autoconfig.thunderbird.net/v1.1/{domain}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(raise_error!(
|
||||||
|
format!("No autoconfig found for domain: {domain}"),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fetch_valid_domain() {
|
||||||
|
let domains = vec![
|
||||||
|
// North America
|
||||||
|
("gmail.com", "Google Gmail"),
|
||||||
|
("outlook.com", "Microsoft Outlook"),
|
||||||
|
("hotmail.com", "Microsoft Hotmail"),
|
||||||
|
("yahoo.com", "Yahoo Mail"),
|
||||||
|
("icloud.com", "Apple iCloud"),
|
||||||
|
("aol.com", "AOL Mail"),
|
||||||
|
("protonmail.com", "ProtonMail"),
|
||||||
|
("zoho.com", "Zoho Mail"),
|
||||||
|
("fastmail.com", "FastMail"),
|
||||||
|
// Europe
|
||||||
|
("gmx.de", "GMX Germany"),
|
||||||
|
("gmx.net", "GMX International"),
|
||||||
|
("web.de", "Web.de Germany"),
|
||||||
|
("freenet.de", "Freenet Germany"),
|
||||||
|
("mail.ru", "Mail.ru Russia"),
|
||||||
|
("yandex.ru", "Yandex Russia"),
|
||||||
|
("orange.fr", "Orange France"),
|
||||||
|
("laposte.net", "La Poste France"),
|
||||||
|
("libero.it", "Libero Italy"),
|
||||||
|
("tiscali.it", "Tiscali Italy"),
|
||||||
|
("telenet.be", "Telenet Belgium"),
|
||||||
|
// Asia Pacific
|
||||||
|
("qq.com", "Tencent QQ"),
|
||||||
|
("163.com", "NetEase 163"),
|
||||||
|
("126.com", "NetEase 126"),
|
||||||
|
("sina.com", "Sina Mail"),
|
||||||
|
("naver.com", "Naver Korea"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (domain, label) in &domains {
|
||||||
|
let result = fetch(domain).await;
|
||||||
|
match result {
|
||||||
|
Ok(config) => println!("✅ [{label}] {domain}: {config:#?}"),
|
||||||
|
Err(e) => println!("⚠️ [{label}] {domain}: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,14 +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 autoconfig::config::OAuth2Config as XOAuth2Config;
|
|
||||||
use poem_openapi::Object;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::modules::account::entity::Encryption;
|
use crate::account::entity::Encryption;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
/// server hostname or IP address
|
/// server hostname or IP address
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -43,7 +41,8 @@ impl ServerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct OAuth2Config {
|
pub struct OAuth2Config {
|
||||||
/// The authorization server's issuer identifier URL
|
/// The authorization server's issuer identifier URL
|
||||||
pub issuer: String,
|
pub issuer: String,
|
||||||
@@ -54,19 +53,8 @@ pub struct OAuth2Config {
|
|||||||
/// URL of the authorization server's token endpoint
|
/// URL of the authorization server's token endpoint
|
||||||
pub token_url: String,
|
pub token_url: String,
|
||||||
}
|
}
|
||||||
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
impl From<&XOAuth2Config> for OAuth2Config {
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
fn from(value: &XOAuth2Config) -> Self {
|
|
||||||
Self {
|
|
||||||
issuer: value.issuer().into(),
|
|
||||||
scope: value.scope().into_iter().map(Into::into).collect(),
|
|
||||||
auth_url: value.auth_url().into(),
|
|
||||||
token_url: value.token_url().into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
|
||||||
pub struct MailServerConfig {
|
pub struct MailServerConfig {
|
||||||
/// IMAP server configuration
|
/// IMAP server configuration
|
||||||
pub imap: ServerConfig,
|
pub imap: ServerConfig,
|
||||||
107
crates/core/src/autoconfig/load.rs
Normal file
107
crates/core/src/autoconfig/load.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::account::entity::Encryption;
|
||||||
|
use crate::autoconfig::client::{self, MailConfig};
|
||||||
|
use crate::autoconfig::entity::{MailServerConfig, ServerConfig};
|
||||||
|
use crate::autoconfig::CachedMailSettings;
|
||||||
|
use crate::error::code::ErrorCode;
|
||||||
|
use crate::error::BichonResult;
|
||||||
|
use crate::raise_error;
|
||||||
|
use email_address::EmailAddress;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
/// Map an autoconfig XML `socketType` value to our `Encryption` enum.
|
||||||
|
pub(crate) fn socket_type_to_encryption(raw: &str) -> Encryption {
|
||||||
|
match raw.to_ascii_uppercase().as_str() {
|
||||||
|
"SSL" | "TLS" => Encryption::Ssl,
|
||||||
|
"STARTTLS" => Encryption::StartTls,
|
||||||
|
_ => Encryption::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the raw `MailConfig` discovered by `client::fetch` into a
|
||||||
|
/// `MailServerConfig` suitable for account provisioning.
|
||||||
|
pub(crate) fn mail_config_to_server_config(config: &MailConfig) -> Option<MailServerConfig> {
|
||||||
|
let imap = config.incoming.iter().find(|s| {
|
||||||
|
let p = s.protocol.to_ascii_lowercase();
|
||||||
|
p == "imap" || p == "imaps"
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let encryption = socket_type_to_encryption(&imap.socket_type);
|
||||||
|
let port = if imap.port != 0 {
|
||||||
|
imap.port
|
||||||
|
} else {
|
||||||
|
match encryption {
|
||||||
|
Encryption::Ssl => 993,
|
||||||
|
_ => 143,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(MailServerConfig {
|
||||||
|
imap: ServerConfig::new(imap.hostname.clone(), port, encryption),
|
||||||
|
oauth2: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_autoconfig(email: impl AsRef<str>) -> BichonResult<Option<MailServerConfig>> {
|
||||||
|
let email = email.as_ref();
|
||||||
|
let email_address = EmailAddress::from_str(email).map_err(|error| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Invalid email address: {email:#?}. {error:#?}"),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let domain = email_address.domain();
|
||||||
|
// Try local cache first
|
||||||
|
if let Some(cached_entity) = CachedMailSettings::get(domain)? {
|
||||||
|
return Ok(Some(cached_entity.config));
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = client::fetch(domain).await.map_err(|e| {
|
||||||
|
error!(
|
||||||
|
email = %email,
|
||||||
|
domain = %domain,
|
||||||
|
error = ?e,
|
||||||
|
"Autoconfig fetch failed"
|
||||||
|
);
|
||||||
|
raise_error!(
|
||||||
|
format!(
|
||||||
|
"Failed to fetch autoconfig for email '{}': {:#?}",
|
||||||
|
email_address.email(),
|
||||||
|
e
|
||||||
|
),
|
||||||
|
ErrorCode::AutoconfigFetchFailed
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let result = mail_config_to_server_config(&config).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!(
|
||||||
|
"No IMAP server found in autoconfig for email: {}",
|
||||||
|
email_address.email()
|
||||||
|
),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
CachedMailSettings::add(domain.into(), result.clone())?;
|
||||||
|
Ok(Some(result))
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,18 +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::database::manager::DB_MANAGER;
|
||||||
use crate::modules::database::manager::DB_MANAGER;
|
use crate::database::{delete_impl, upsert_impl};
|
||||||
use crate::modules::database::{delete_impl, async_find_impl, upsert_impl};
|
use crate::database::{find_impl, MemDbModel};
|
||||||
use crate::modules::error::code::ErrorCode;
|
use crate::{autoconfig::entity::MailServerConfig, error::BichonResult, utc_now};
|
||||||
use crate::raise_error;
|
|
||||||
use crate::{
|
|
||||||
modules::autoconfig::entity::MailServerConfig, modules::error::BichonResult, utc_now,
|
|
||||||
};
|
|
||||||
use native_db::*;
|
|
||||||
use native_model::{native_model, Model};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod load;
|
pub mod load;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -36,45 +31,39 @@ mod tests;
|
|||||||
const EXPIRE_TIME_MS: i64 = 30 * 24 * 60 * 60 * 1000;
|
const EXPIRE_TIME_MS: i64 = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
#[native_model(id = 3, version = 1)]
|
|
||||||
#[native_db]
|
|
||||||
pub struct CachedMailSettings {
|
pub struct CachedMailSettings {
|
||||||
#[primary_key]
|
|
||||||
pub domain: String,
|
pub domain: String,
|
||||||
pub config: MailServerConfig,
|
pub config: MailServerConfig,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MemDbModel for CachedMailSettings {
|
||||||
|
fn collection() -> &'static str {
|
||||||
|
"autoconfig"
|
||||||
|
}
|
||||||
|
fn key(&self) -> String {
|
||||||
|
self.domain.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl CachedMailSettings {
|
impl CachedMailSettings {
|
||||||
pub async fn add(domain: String, config: MailServerConfig) -> BichonResult<()> {
|
pub fn add(domain: String, config: MailServerConfig) -> BichonResult<()> {
|
||||||
Self {
|
Self {
|
||||||
domain,
|
domain,
|
||||||
config,
|
config,
|
||||||
created_at: utc_now!(),
|
created_at: utc_now!(),
|
||||||
}
|
}
|
||||||
.save()
|
.save()
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save(&self) -> BichonResult<()> {
|
fn save(&self) -> BichonResult<()> {
|
||||||
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
|
upsert_impl(DB_MANAGER.db(), self.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(domain: &str) -> BichonResult<Option<CachedMailSettings>> {
|
pub fn get(domain: &str) -> BichonResult<Option<CachedMailSettings>> {
|
||||||
if let Some(found) =
|
if let Some(found) = find_impl::<CachedMailSettings>(DB_MANAGER.db(), domain)? {
|
||||||
async_find_impl::<CachedMailSettings>(DB_MANAGER.meta_db(), domain.to_string()).await?
|
|
||||||
{
|
|
||||||
if (utc_now!() - found.created_at) > EXPIRE_TIME_MS {
|
if (utc_now!() - found.created_at) > EXPIRE_TIME_MS {
|
||||||
let domain = domain.to_string();
|
delete_impl::<CachedMailSettings>(DB_MANAGER.db(), domain)?;
|
||||||
delete_impl(DB_MANAGER.meta_db(), |rw| {
|
|
||||||
rw.get()
|
|
||||||
.primary::<CachedMailSettings>(domain)
|
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
raise_error!("auto config cache miss".into(), ErrorCode::InternalError)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
Ok(Some(found))
|
Ok(Some(found))
|
||||||
308
crates/core/src/autoconfig/tests.rs
Normal file
308
crates/core/src/autoconfig/tests.rs
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::account::entity::Encryption;
|
||||||
|
use crate::autoconfig::client::{self, IncomingServer, MailConfig};
|
||||||
|
use crate::autoconfig::load::{mail_config_to_server_config, socket_type_to_encryption};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// XML parsing tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn make_valid_xml() -> String {
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<clientConfig version="1.1">
|
||||||
|
<emailProvider id="example.com">
|
||||||
|
<domain>example.com</domain>
|
||||||
|
<displayName>Example Mail</displayName>
|
||||||
|
<incomingServer type="imap">
|
||||||
|
<hostname>imap.example.com</hostname>
|
||||||
|
<port>993</port>
|
||||||
|
<socketType>SSL</socketType>
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
</incomingServer>
|
||||||
|
<outgoingServer type="smtp">
|
||||||
|
<hostname>smtp.example.com</hostname>
|
||||||
|
<port>587</port>
|
||||||
|
<socketType>STARTTLS</socketType>
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
</outgoingServer>
|
||||||
|
</emailProvider>
|
||||||
|
</clientConfig>"#
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_valid_xml() {
|
||||||
|
let xml = make_valid_xml();
|
||||||
|
let config = client::parse_autoconfig_xml(&xml).expect("should parse valid XML");
|
||||||
|
|
||||||
|
assert_eq!(config.incoming.len(), 1);
|
||||||
|
let imap = &config.incoming[0];
|
||||||
|
assert_eq!(imap.protocol, "imap");
|
||||||
|
assert_eq!(imap.hostname, "imap.example.com");
|
||||||
|
assert_eq!(imap.port, 993);
|
||||||
|
assert_eq!(imap.socket_type, "SSL");
|
||||||
|
assert_eq!(imap.username, "%EMAILADDRESS%");
|
||||||
|
|
||||||
|
assert_eq!(config.outgoing.len(), 1);
|
||||||
|
let smtp = &config.outgoing[0];
|
||||||
|
assert_eq!(smtp.protocol, "smtp");
|
||||||
|
assert_eq!(smtp.hostname, "smtp.example.com");
|
||||||
|
assert_eq!(smtp.port, 587);
|
||||||
|
assert_eq!(smtp.socket_type, "STARTTLS");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_xml_empty_body() {
|
||||||
|
let xml = r#"<?xml version="1.0"?><clientConfig></clientConfig>"#;
|
||||||
|
let config = client::parse_autoconfig_xml(xml);
|
||||||
|
assert!(config.is_none(), "no emailProvider → None");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_xml_no_incoming_servers() {
|
||||||
|
let xml = r#"<?xml version="1.0"?>
|
||||||
|
<clientConfig version="1.1">
|
||||||
|
<emailProvider id="example.com">
|
||||||
|
<domain>example.com</domain>
|
||||||
|
</emailProvider>
|
||||||
|
</clientConfig>"#;
|
||||||
|
let config = client::parse_autoconfig_xml(xml).expect("should parse");
|
||||||
|
assert!(config.incoming.is_empty());
|
||||||
|
assert!(config.outgoing.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_xml_garbage() {
|
||||||
|
let config = client::parse_autoconfig_xml("not xml at all");
|
||||||
|
assert!(config.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_xml_missing_port_defaults_to_zero() {
|
||||||
|
let xml = r#"<?xml version="1.0"?>
|
||||||
|
<clientConfig version="1.1">
|
||||||
|
<emailProvider id="example.com">
|
||||||
|
<incomingServer type="imap">
|
||||||
|
<hostname>imap.example.com</hostname>
|
||||||
|
<socketType>SSL</socketType>
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
</incomingServer>
|
||||||
|
</emailProvider>
|
||||||
|
</clientConfig>"#;
|
||||||
|
let config = client::parse_autoconfig_xml(xml).expect("should parse");
|
||||||
|
assert_eq!(config.incoming[0].port, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_xml_multiple_providers_picks_first() {
|
||||||
|
let xml = r#"<?xml version="1.0"?>
|
||||||
|
<clientConfig version="1.1">
|
||||||
|
<emailProvider id="first.example.com">
|
||||||
|
<incomingServer type="imap">
|
||||||
|
<hostname>imap.first.example.com</hostname>
|
||||||
|
<port>993</port>
|
||||||
|
<socketType>SSL</socketType>
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
</incomingServer>
|
||||||
|
</emailProvider>
|
||||||
|
<emailProvider id="second.example.com">
|
||||||
|
<incomingServer type="imap">
|
||||||
|
<hostname>imap.second.example.com</hostname>
|
||||||
|
<port>143</port>
|
||||||
|
<socketType>STARTTLS</socketType>
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
</incomingServer>
|
||||||
|
</emailProvider>
|
||||||
|
</clientConfig>"#;
|
||||||
|
let config = client::parse_autoconfig_xml(xml).expect("should parse");
|
||||||
|
assert_eq!(config.incoming[0].hostname, "imap.first.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// socket_type → Encryption mapping tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_ssl_uppercase() {
|
||||||
|
assert_eq!(socket_type_to_encryption("SSL"), Encryption::Ssl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_ssl_lowercase() {
|
||||||
|
assert_eq!(socket_type_to_encryption("ssl"), Encryption::Ssl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_tls() {
|
||||||
|
assert_eq!(socket_type_to_encryption("TLS"), Encryption::Ssl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_starttls() {
|
||||||
|
assert_eq!(socket_type_to_encryption("STARTTLS"), Encryption::StartTls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_starttls_lowercase() {
|
||||||
|
assert_eq!(socket_type_to_encryption("starttls"), Encryption::StartTls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_starttls_mixed_case() {
|
||||||
|
assert_eq!(socket_type_to_encryption("StartTls"), Encryption::StartTls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_plain() {
|
||||||
|
assert_eq!(socket_type_to_encryption("plain"), Encryption::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_empty_string() {
|
||||||
|
assert_eq!(socket_type_to_encryption(""), Encryption::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_unknown_value() {
|
||||||
|
assert_eq!(socket_type_to_encryption("WPA2-ENTERPRISE"), Encryption::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MailConfig → MailServerConfig conversion tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn make_imap_server(host: &str, port: u16, socket_type: &str) -> IncomingServer {
|
||||||
|
IncomingServer {
|
||||||
|
protocol: "imap".to_string(),
|
||||||
|
hostname: host.to_string(),
|
||||||
|
port,
|
||||||
|
socket_type: socket_type.to_string(),
|
||||||
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_basic_imap_ssl() {
|
||||||
|
let config = MailConfig {
|
||||||
|
incoming: vec![make_imap_server("imap.example.com", 993, "SSL")],
|
||||||
|
outgoing: vec![],
|
||||||
|
};
|
||||||
|
let result = mail_config_to_server_config(&config).expect("should convert");
|
||||||
|
assert_eq!(result.imap.host, "imap.example.com");
|
||||||
|
assert_eq!(result.imap.port, 993);
|
||||||
|
assert_eq!(result.imap.encryption, Encryption::Ssl);
|
||||||
|
assert!(result.oauth2.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_imap_starttls_with_default_port() {
|
||||||
|
let config = MailConfig {
|
||||||
|
incoming: vec![make_imap_server("imap.example.com", 0, "STARTTLS")],
|
||||||
|
outgoing: vec![],
|
||||||
|
};
|
||||||
|
let result = mail_config_to_server_config(&config).expect("should convert");
|
||||||
|
assert_eq!(result.imap.port, 143, "default port for STARTTLS → 143");
|
||||||
|
assert_eq!(result.imap.encryption, Encryption::StartTls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_imap_ssl_with_default_port() {
|
||||||
|
let config = MailConfig {
|
||||||
|
incoming: vec![make_imap_server("imap.example.com", 0, "SSL")],
|
||||||
|
outgoing: vec![],
|
||||||
|
};
|
||||||
|
let result = mail_config_to_server_config(&config).expect("should convert");
|
||||||
|
assert_eq!(result.imap.port, 993, "default port for SSL → 993");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_no_imap_only_pop3() {
|
||||||
|
let config = MailConfig {
|
||||||
|
incoming: vec![IncomingServer {
|
||||||
|
protocol: "pop3".to_string(),
|
||||||
|
hostname: "pop.example.com".to_string(),
|
||||||
|
port: 995,
|
||||||
|
socket_type: "SSL".to_string(),
|
||||||
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
}],
|
||||||
|
outgoing: vec![],
|
||||||
|
};
|
||||||
|
assert!(mail_config_to_server_config(&config).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_empty_incoming() {
|
||||||
|
let config = MailConfig {
|
||||||
|
incoming: vec![],
|
||||||
|
outgoing: vec![],
|
||||||
|
};
|
||||||
|
assert!(mail_config_to_server_config(&config).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_picks_imap_over_pop3() {
|
||||||
|
let config = MailConfig {
|
||||||
|
incoming: vec![
|
||||||
|
IncomingServer {
|
||||||
|
protocol: "pop3".to_string(),
|
||||||
|
hostname: "pop.example.com".to_string(),
|
||||||
|
port: 995,
|
||||||
|
socket_type: "SSL".to_string(),
|
||||||
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
},
|
||||||
|
make_imap_server("imap.example.com", 993, "SSL"),
|
||||||
|
],
|
||||||
|
outgoing: vec![],
|
||||||
|
};
|
||||||
|
let result = mail_config_to_server_config(&config).expect("should find IMAP");
|
||||||
|
assert_eq!(result.imap.host, "imap.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_imaps_protocol_variant() {
|
||||||
|
let config = MailConfig {
|
||||||
|
incoming: vec![IncomingServer {
|
||||||
|
protocol: "imaps".to_string(),
|
||||||
|
hostname: "imap.example.com".to_string(),
|
||||||
|
port: 993,
|
||||||
|
socket_type: "SSL".to_string(),
|
||||||
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
}],
|
||||||
|
outgoing: vec![],
|
||||||
|
};
|
||||||
|
let result = mail_config_to_server_config(&config).expect("should recognize 'imaps'");
|
||||||
|
assert_eq!(result.imap.host, "imap.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_case_insensitive_protocol() {
|
||||||
|
let config = MailConfig {
|
||||||
|
incoming: vec![IncomingServer {
|
||||||
|
protocol: "IMAP".to_string(),
|
||||||
|
hostname: "imap.example.com".to_string(),
|
||||||
|
port: 143,
|
||||||
|
socket_type: "STARTTLS".to_string(),
|
||||||
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
}],
|
||||||
|
outgoing: vec![],
|
||||||
|
};
|
||||||
|
let result = mail_config_to_server_config(&config).expect("should recognize 'IMAP'");
|
||||||
|
assert_eq!(result.imap.host, "imap.example.com");
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,27 +16,28 @@
|
|||||||
// 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 std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
decode_mailbox_name,
|
decode_mailbox_name, raise_error,
|
||||||
modules::{
|
{
|
||||||
account::migration::{AccountModel, AccountType},
|
account::migration::{AccountModel, AccountType},
|
||||||
cache::imap::mailbox::{AttributeEnum, MailBox},
|
cache::imap::mailbox::{AttributeEnum, MailBox},
|
||||||
context::executors::MAIL_CONTEXT,
|
cache::imap::mailbox_cache,
|
||||||
error::{code::ErrorCode, BichonResult},
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
imap::{executor::ImapExecutor, session::SessionStream},
|
||||||
mailbox::list::convert_names_to_mailboxes,
|
mailbox::list::convert_names_to_mailboxes,
|
||||||
},
|
},
|
||||||
raise_error,
|
|
||||||
};
|
};
|
||||||
use async_imap::types::Name;
|
use async_imap::{types::Name, Session};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBox>> {
|
pub async fn get_download_folders(
|
||||||
|
account: &AccountModel,
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
) -> BichonResult<Vec<MailBox>> {
|
||||||
assert_eq!(account.account_type, AccountType::IMAP);
|
assert_eq!(account.account_type, AccountType::IMAP);
|
||||||
let executor = MAIL_CONTEXT.imap(account.id).await?;
|
let names = ImapExecutor::list_all_mailboxes(session).await?;
|
||||||
let names = executor.list_all_mailboxes().await?;
|
|
||||||
if names.is_empty() {
|
if names.is_empty() {
|
||||||
warn!(
|
warn!(
|
||||||
"Account {}: No mailboxes returned from IMAP server.",
|
"Account {}: No mailboxes returned from IMAP server.",
|
||||||
@@ -61,8 +62,8 @@ pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBo
|
|||||||
mailboxes.iter().map(|(m, _)| m.name.clone()).collect(),
|
mailboxes.iter().map(|(m, _)| m.name.clone()).collect(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let account = AccountModel::get(account.id).await?;
|
let account = AccountModel::get(account.id)?;
|
||||||
let subscribed = &account.sync_folders.unwrap_or_default();
|
let subscribed = &account.download_folders.unwrap_or_default();
|
||||||
let is_noselect = |mailbox: &MailBox| {
|
let is_noselect = |mailbox: &MailBox| {
|
||||||
mailbox
|
mailbox
|
||||||
.attributes
|
.attributes
|
||||||
@@ -109,7 +110,7 @@ pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBo
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|n| decode_mailbox_name!(n.name().to_string()))
|
.map(|n| decode_mailbox_name!(n.name().to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
AccountModel::update_sync_folders(account.id, sync_folders).await?;
|
AccountModel::update_download_folders(account.id, sync_folders)?;
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
"Account {}: No subscribed mailboxes found. This is unexpected — IMAP server should at least provide INBOX.",
|
"Account {}: No subscribed mailboxes found. This is unexpected — IMAP server should at least provide INBOX.",
|
||||||
@@ -121,7 +122,7 @@ pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBo
|
|||||||
), ErrorCode::ImapUnexpectedResult));
|
), ErrorCode::ImapUnexpectedResult));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
convert_names_to_mailboxes(account.id, matched_mailboxes).await
|
convert_names_to_mailboxes(account.id, session, matched_mailboxes).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn detect_mailbox_changes(
|
pub async fn detect_mailbox_changes(
|
||||||
@@ -130,7 +131,7 @@ pub async fn detect_mailbox_changes(
|
|||||||
) -> BichonResult<()> {
|
) -> BichonResult<()> {
|
||||||
if account.known_folders.is_none() {
|
if account.known_folders.is_none() {
|
||||||
// First time sync: just save without comparing
|
// First time sync: just save without comparing
|
||||||
AccountModel::update_known_folders(account.id, all_names).await?;
|
AccountModel::update_known_folders(account.id, all_names)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let known_folders = account.known_folders.clone().unwrap_or_default();
|
let known_folders = account.known_folders.clone().unwrap_or_default();
|
||||||
@@ -139,19 +140,19 @@ pub async fn detect_mailbox_changes(
|
|||||||
let deleted_folders: Vec<String> = known_folders.difference(&all_names).cloned().collect();
|
let deleted_folders: Vec<String> = known_folders.difference(&all_names).cloned().collect();
|
||||||
|
|
||||||
let has_changes = !new_folders.is_empty() || !deleted_folders.is_empty();
|
let has_changes = !new_folders.is_empty() || !deleted_folders.is_empty();
|
||||||
let sync_folders = account.sync_folders.as_deref().unwrap_or_default();
|
let download_folders = account.download_folders.as_deref().unwrap_or_default();
|
||||||
// Handle deleted folders in sync_folders
|
// Handle deleted folders in sync_folders
|
||||||
if !deleted_folders.is_empty() {
|
if !deleted_folders.is_empty() {
|
||||||
// Check if any deleted folders are in sync_folders
|
// Check if any deleted folders are in sync_folders
|
||||||
let remaining_sync_folders: Vec<String> = sync_folders
|
let remaining_sync_folders: Vec<String> = download_folders
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|folder| !deleted_folders.contains(folder))
|
.filter(|folder| !deleted_folders.contains(folder))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// If sync_folders changed, update them
|
// If sync_folders changed, update them
|
||||||
if remaining_sync_folders.len() != sync_folders.len() {
|
if remaining_sync_folders.len() != download_folders.len() {
|
||||||
let removed_count = sync_folders.len() - remaining_sync_folders.len();
|
let removed_count = download_folders.len() - remaining_sync_folders.len();
|
||||||
info!(
|
info!(
|
||||||
"Account {}: Removed {} deleted folders from sync_folders",
|
"Account {}: Removed {} deleted folders from sync_folders",
|
||||||
account.id, removed_count
|
account.id, removed_count
|
||||||
@@ -159,7 +160,7 @@ pub async fn detect_mailbox_changes(
|
|||||||
// Note: When all subscribed folders are deleted (remaining_sync_folders empty),
|
// Note: When all subscribed folders are deleted (remaining_sync_folders empty),
|
||||||
// the system's default behavior is to automatically fall back to syncing
|
// the system's default behavior is to automatically fall back to syncing
|
||||||
// only the default folders (INBOX and Sent) in subsequent operations
|
// only the default folders (INBOX and Sent) in subsequent operations
|
||||||
AccountModel::update_sync_folders(account.id, remaining_sync_folders).await?;
|
AccountModel::update_download_folders(account.id, remaining_sync_folders)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -174,11 +175,22 @@ pub async fn detect_mailbox_changes(
|
|||||||
"Account {}: New folders detected: {:?}",
|
"Account {}: New folders detected: {:?}",
|
||||||
account.id, new_folders
|
account.id, new_folders
|
||||||
);
|
);
|
||||||
|
if account.auto_download_new_mailboxes.unwrap_or(false) {
|
||||||
|
let mut updated: Vec<String> = download_folders.to_vec();
|
||||||
|
updated.extend(new_folders.iter().cloned());
|
||||||
|
AccountModel::update_download_folders(account.id, updated)?;
|
||||||
|
info!(
|
||||||
|
"Account {}: Auto-added {} new folders to download list",
|
||||||
|
account.id,
|
||||||
|
new_folders.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update known folders only if there were changes
|
// Update known folders only if there were changes
|
||||||
if has_changes {
|
if has_changes {
|
||||||
AccountModel::update_known_folders(account.id, all_names).await?;
|
AccountModel::update_known_folders(account.id, all_names)?;
|
||||||
|
mailbox_cache::invalidate(account.id).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
73
crates/core/src/cache/imap/download/download_type.rs
vendored
Normal file
73
crates/core/src/cache/imap/download/download_type.rs
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
utc_now,
|
||||||
|
{
|
||||||
|
account::{
|
||||||
|
migration::AccountModel,
|
||||||
|
state::{DownloadState, TriggerType},
|
||||||
|
},
|
||||||
|
error::BichonResult,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum DownloadTask {
|
||||||
|
FullFetch,
|
||||||
|
TraceFetch,
|
||||||
|
Idle,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn decide_next_download_task(
|
||||||
|
account: &AccountModel,
|
||||||
|
trigger_type: TriggerType,
|
||||||
|
) -> BichonResult<DownloadTask> {
|
||||||
|
let state = match DownloadState::get(account.id)? {
|
||||||
|
None => {
|
||||||
|
DownloadState::init(account.id).await?;
|
||||||
|
return Ok(DownloadTask::FullFetch);
|
||||||
|
}
|
||||||
|
Some(s) => s,
|
||||||
|
};
|
||||||
|
|
||||||
|
let should_start = match trigger_type {
|
||||||
|
TriggerType::Manual => true,
|
||||||
|
TriggerType::Scheduled => should_trigger_next_download(
|
||||||
|
state.last_trigger_at,
|
||||||
|
state.last_finished_at.unwrap_or(0),
|
||||||
|
account.download_interval_min.unwrap_or(60),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_start {
|
||||||
|
DownloadState::start_new_session(account.id, trigger_type)?;
|
||||||
|
Ok(DownloadTask::TraceFetch)
|
||||||
|
} else {
|
||||||
|
Ok(DownloadTask::Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_trigger_next_download(
|
||||||
|
last_trigger_at: i64,
|
||||||
|
last_finished_at: i64,
|
||||||
|
sync_interval_min: i64,
|
||||||
|
) -> bool {
|
||||||
|
let now = utc_now!();
|
||||||
|
now - last_trigger_at > (sync_interval_min * 60 * 1000) && now - last_finished_at > 60 * 1000
|
||||||
|
}
|
||||||
678
crates/core/src/cache/imap/download/flow.rs
vendored
Normal file
678
crates/core/src/cache/imap/download/flow.rs
vendored
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
raise_error,
|
||||||
|
{
|
||||||
|
account::{
|
||||||
|
migration::AccountModel,
|
||||||
|
state::{DownloadState, DownloadStatus, FolderStatus},
|
||||||
|
},
|
||||||
|
cache::{
|
||||||
|
imap::{
|
||||||
|
download::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
|
||||||
|
find_intersecting_mailboxes, find_missing_mailboxes,
|
||||||
|
mailbox::MailBox,
|
||||||
|
},
|
||||||
|
SEMAPHORE,
|
||||||
|
},
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
imap::executor::ImapExecutor,
|
||||||
|
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
pub const DEFAULT_BATCH_SIZE: u32 = 30;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum FetchDirection {
|
||||||
|
Since,
|
||||||
|
Before,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_and_save_by_date(
|
||||||
|
account: &AccountModel,
|
||||||
|
date: &str,
|
||||||
|
mailbox: &MailBox,
|
||||||
|
direction: FetchDirection,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
let account_id = account.id;
|
||||||
|
let mut session = match ImapExecutor::create_connection(account_id).await {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Connection failed for this folder: {:#?}", e);
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some(err_msg.clone()),
|
||||||
|
)?;
|
||||||
|
DownloadState::append_session_error(account_id, err_msg)?;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let search_criteria = match direction {
|
||||||
|
FetchDirection::Since => format!("SINCE {date}"),
|
||||||
|
FetchDirection::Before => format!("BEFORE {date}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let uid_list =
|
||||||
|
match ImapExecutor::uid_search(&mut session, &mailbox.encoded_name(), &search_criteria)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(uid_list) => uid_list,
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("UID search failed in [{}]: {:#?}", mailbox.name, e);
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some(err_msg.clone()),
|
||||||
|
)?;
|
||||||
|
DownloadState::append_session_error(account_id, err_msg)?;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let len = uid_list.len();
|
||||||
|
if len == 0 {
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Success,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder_limit = account.folder_limit;
|
||||||
|
// sort small -> bigger
|
||||||
|
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
|
||||||
|
uid_vec.sort();
|
||||||
|
|
||||||
|
if let Some(limit) = folder_limit {
|
||||||
|
let limit = limit.max(100) as usize;
|
||||||
|
if len > limit {
|
||||||
|
uid_vec = match direction {
|
||||||
|
FetchDirection::Since => uid_vec.split_off(len - limit),
|
||||||
|
FetchDirection::Before => {
|
||||||
|
uid_vec.truncate(limit);
|
||||||
|
uid_vec
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let planned = uid_vec.len() as u64;
|
||||||
|
let uid_batches = generate_uid_sequence_hashset(
|
||||||
|
uid_vec,
|
||||||
|
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
planned,
|
||||||
|
0,
|
||||||
|
FolderStatus::Pending,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut current_processed = 0u64;
|
||||||
|
let mut has_error_or_cancel = false;
|
||||||
|
for (index, batch) in uid_batches.into_iter().enumerate() {
|
||||||
|
if token.is_cancelled() {
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account_id,
|
||||||
|
DownloadStatus::Cancelled,
|
||||||
|
Some("User stopped or system shutdown".to_string()),
|
||||||
|
)?;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
planned,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Cancelled,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
has_error_or_cancel = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Fetch metadata for the current batch of UIDs
|
||||||
|
match ImapExecutor::uid_batch_retrieve_emails(
|
||||||
|
&mut session,
|
||||||
|
account_id,
|
||||||
|
mailbox.id,
|
||||||
|
&batch.0,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
current_processed += batch.1;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
planned,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Downloading,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Batch {} failed: {:#?}", index, e);
|
||||||
|
DownloadState::append_session_error(account_id, err_msg.clone())?;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
planned,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some(err_msg),
|
||||||
|
)?;
|
||||||
|
has_error_or_cancel = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_error_or_cancel {
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
planned,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Success,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
session.logout().await.ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_and_save_full_mailbox(
|
||||||
|
account: &AccountModel,
|
||||||
|
mailbox: &MailBox,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
let mailbox_id = mailbox.id;
|
||||||
|
let account_id = account.id;
|
||||||
|
|
||||||
|
let mut session = match ImapExecutor::create_connection(account_id).await {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Connection failed for this folder: {:#?}", e);
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some(err_msg.clone()),
|
||||||
|
)?;
|
||||||
|
DownloadState::append_session_error(account_id, err_msg)?;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = match session.examine(&mailbox.encoded_name()).await {
|
||||||
|
Ok(mailbox) => mailbox.exists as u64,
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Failed to examine folder [{}]: {:#?}", mailbox.name, e);
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
mailbox.exists as u64,
|
||||||
|
0,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some(err_msg.clone()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
DownloadState::append_session_error(account_id, err_msg)?;
|
||||||
|
session.logout().await.ok();
|
||||||
|
return Err(raise_error!(
|
||||||
|
format!("{:#?}", e),
|
||||||
|
ErrorCode::ImapCommandFailed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let folder_limit = account.folder_limit;
|
||||||
|
let total_to_fetch = match folder_limit {
|
||||||
|
Some(limit) if (limit as u64) < total => {
|
||||||
|
let limit64 = limit as u64;
|
||||||
|
total.min(limit64.max(100))
|
||||||
|
}
|
||||||
|
_ => total,
|
||||||
|
};
|
||||||
|
|
||||||
|
let page_size = if let Some(limit) = folder_limit {
|
||||||
|
limit
|
||||||
|
.max(100)
|
||||||
|
.min(account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE))
|
||||||
|
} else {
|
||||||
|
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE)
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_batches = total_to_fetch.div_ceil(page_size as u64);
|
||||||
|
let desc = folder_limit.is_some();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Starting full mailbox download for '{}', total={}, limit={:?}, batches={}, desc={}",
|
||||||
|
mailbox.name, total, folder_limit, total_batches, desc
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut current_processed = 0u64;
|
||||||
|
let mut has_error_or_cancel = false;
|
||||||
|
|
||||||
|
for page in 1..=total_batches {
|
||||||
|
if token.is_cancelled() {
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account_id,
|
||||||
|
DownloadStatus::Cancelled,
|
||||||
|
Some("User stopped or system shutdown".to_string()),
|
||||||
|
)?;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
total_to_fetch,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Cancelled,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
has_error_or_cancel = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ImapExecutor::batch_retrieve_emails(
|
||||||
|
&mut session,
|
||||||
|
account_id,
|
||||||
|
mailbox_id,
|
||||||
|
total_to_fetch,
|
||||||
|
page as u64,
|
||||||
|
page_size as u64,
|
||||||
|
&mailbox.encoded_name(),
|
||||||
|
desc,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(count) => {
|
||||||
|
current_processed += count as u64;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
total_to_fetch,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Downloading,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Batch {} failed: {:#?}", page, e);
|
||||||
|
DownloadState::append_session_error(account_id, err_msg.clone())?;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
total_to_fetch,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some(err_msg),
|
||||||
|
)?;
|
||||||
|
has_error_or_cancel = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_error_or_cancel {
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
total_to_fetch,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Success,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
session.logout().await.ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_uid_sequence_hashset(
|
||||||
|
unique_nums: Vec<u32>,
|
||||||
|
chunk_size: usize,
|
||||||
|
desc: bool,
|
||||||
|
) -> Vec<(String, u64)> {
|
||||||
|
assert!(!unique_nums.is_empty());
|
||||||
|
let mut nums = unique_nums;
|
||||||
|
if desc {
|
||||||
|
nums.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
for chunk in nums.chunks(chunk_size) {
|
||||||
|
let size = chunk.len() as u64;
|
||||||
|
let compressed = compress_uid_list(chunk.to_vec());
|
||||||
|
result.push((compressed, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compress_uid_list(nums: Vec<u32>) -> String {
|
||||||
|
if nums.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted_nums = nums;
|
||||||
|
sorted_nums.sort();
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut current_range_start = sorted_nums[0];
|
||||||
|
let mut current_range_end = sorted_nums[0];
|
||||||
|
|
||||||
|
for &n in sorted_nums.iter().skip(1) {
|
||||||
|
if n == current_range_end + 1 {
|
||||||
|
current_range_end = n;
|
||||||
|
} else {
|
||||||
|
if current_range_start == current_range_end {
|
||||||
|
result.push(current_range_start.to_string());
|
||||||
|
} else {
|
||||||
|
result.push(format!("{}:{}", current_range_start, current_range_end));
|
||||||
|
}
|
||||||
|
current_range_start = n;
|
||||||
|
current_range_end = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_range_start == current_range_end {
|
||||||
|
result.push(current_range_start.to_string());
|
||||||
|
} else {
|
||||||
|
result.push(format!("{}:{}", current_range_start, current_range_end));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reconcile_mailboxes(
|
||||||
|
account: &AccountModel,
|
||||||
|
remote_mailboxes: &[MailBox],
|
||||||
|
local_mailboxes: &[MailBox],
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
let start_time = Instant::now();
|
||||||
|
let existing_mailboxes = find_intersecting_mailboxes(local_mailboxes, remote_mailboxes);
|
||||||
|
let account_id = account.id;
|
||||||
|
if !existing_mailboxes.is_empty() {
|
||||||
|
let mut mailboxes_to_update = Vec::with_capacity(existing_mailboxes.len());
|
||||||
|
|
||||||
|
DownloadState::init_folder_details(
|
||||||
|
account.id,
|
||||||
|
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for (local_mailbox, remote_mailbox) in &existing_mailboxes {
|
||||||
|
if token.is_cancelled() {
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account.id,
|
||||||
|
DownloadStatus::Cancelled,
|
||||||
|
Some("Received termination signal (User stop or System shutdown)".to_string()),
|
||||||
|
)?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if local_mailbox.uid_validity != remote_mailbox.uid_validity {
|
||||||
|
if remote_mailbox.uid_validity.is_none() {
|
||||||
|
let err_msg = format!(
|
||||||
|
"Mailbox '{}' logic error: Server did not provide UIDVALIDITY.",
|
||||||
|
local_mailbox.name
|
||||||
|
);
|
||||||
|
|
||||||
|
warn!("Account {}: {}", account_id, err_msg);
|
||||||
|
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
remote_mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some(err_msg.clone()),
|
||||||
|
)?;
|
||||||
|
DownloadState::append_session_error(account_id, err_msg)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Account {}: Mailbox '{}' detected with changed uid_validity (local: {:#?}, remote: {:#?}). \
|
||||||
|
The mailbox data may be invalid, resetting its envelopes and rebuilding the cache.",
|
||||||
|
account_id, local_mailbox.name, &local_mailbox.uid_validity, &remote_mailbox.uid_validity
|
||||||
|
);
|
||||||
|
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account_id,
|
||||||
|
local_mailbox.name.clone(),
|
||||||
|
remote_mailbox.exists as u64,
|
||||||
|
0,
|
||||||
|
FolderStatus::Downloading,
|
||||||
|
Some("UID validity changed, rebuilding...".into()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
match &account.date_since {
|
||||||
|
Some(date_since) => {
|
||||||
|
rebuild_mailbox_cache_by_date(
|
||||||
|
account,
|
||||||
|
local_mailbox.id,
|
||||||
|
&date_since.since_date()?,
|
||||||
|
remote_mailbox,
|
||||||
|
FetchDirection::Since,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
None => match &account.date_before {
|
||||||
|
Some(r) => {
|
||||||
|
rebuild_mailbox_cache_by_date(
|
||||||
|
account,
|
||||||
|
local_mailbox.id,
|
||||||
|
&r.calculate_date()?,
|
||||||
|
remote_mailbox,
|
||||||
|
FetchDirection::Before,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
rebuild_mailbox_cache(
|
||||||
|
account,
|
||||||
|
local_mailbox,
|
||||||
|
remote_mailbox,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
perform_incremental_sync(account, local_mailbox, remote_mailbox, token.clone())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
mailboxes_to_update.push(remote_mailbox.clone());
|
||||||
|
}
|
||||||
|
//The metadata of this mailbox must only be updated after a successful synchronization;
|
||||||
|
//otherwise, it may cause synchronization errors and result in missing emails in the local sync results.
|
||||||
|
MailBox::batch_upsert(&mailboxes_to_update)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Checked mailbox folders for account ID: {}. Compared local and server folders to identify changes. Elapsed time: {} seconds",
|
||||||
|
account.id,
|
||||||
|
start_time.elapsed().as_secs()
|
||||||
|
);
|
||||||
|
|
||||||
|
let missing_mailboxes = find_missing_mailboxes(local_mailboxes, remote_mailboxes);
|
||||||
|
//Mail folders that are not locally need to be downloaded.
|
||||||
|
if !missing_mailboxes.is_empty() {
|
||||||
|
MailBox::batch_insert(&missing_mailboxes)?;
|
||||||
|
|
||||||
|
let mut has_error = false;
|
||||||
|
let mut last_err = None;
|
||||||
|
for mailbox in &missing_mailboxes {
|
||||||
|
if token.is_cancelled() {
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account.id,
|
||||||
|
DownloadStatus::Cancelled,
|
||||||
|
Some("Received termination signal (User stop or System shutdown)".to_string()),
|
||||||
|
)?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if mailbox.exists > 0 {
|
||||||
|
let account = account.clone();
|
||||||
|
let mailbox = mailbox.clone();
|
||||||
|
|
||||||
|
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
|
||||||
|
account.id, &mailbox.name, err
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = match &account.date_since {
|
||||||
|
Some(date_since) => {
|
||||||
|
rebuild_mailbox_cache_by_date(
|
||||||
|
&account,
|
||||||
|
mailbox.id,
|
||||||
|
&date_since.since_date()?,
|
||||||
|
&mailbox,
|
||||||
|
FetchDirection::Since,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => match &account.date_before {
|
||||||
|
Some(r) => {
|
||||||
|
rebuild_mailbox_cache_by_date(
|
||||||
|
&account,
|
||||||
|
mailbox.id,
|
||||||
|
&r.calculate_date()?,
|
||||||
|
&mailbox,
|
||||||
|
FetchDirection::Before,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
rebuild_mailbox_cache(&account, &mailbox, &mailbox, token.clone()).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => {
|
||||||
|
has_error = true;
|
||||||
|
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||||
|
last_err = Some(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_error {
|
||||||
|
if let Some(e) = last_err {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Some tasks failed".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
//only check new emails and sync
|
||||||
|
async fn perform_incremental_sync(
|
||||||
|
account: &AccountModel,
|
||||||
|
local_mailbox: &MailBox,
|
||||||
|
remote_mailbox: &MailBox,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
if remote_mailbox.exists > 0 {
|
||||||
|
let local_max_uid = ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
|
||||||
|
match local_max_uid {
|
||||||
|
Some(max_uid) => {
|
||||||
|
let mut session = ImapExecutor::create_connection(account.id).await?;
|
||||||
|
let before_date = account
|
||||||
|
.date_before
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.calculate_date())
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
ImapExecutor::fetch_new_mail(
|
||||||
|
&mut session,
|
||||||
|
account,
|
||||||
|
local_mailbox,
|
||||||
|
max_uid + 1,
|
||||||
|
before_date.as_deref(),
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
session.logout().await.ok();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!(
|
||||||
|
"No maximum UID found in index for mailbox, assuming local cache is missing."
|
||||||
|
);
|
||||||
|
|
||||||
|
match &account.date_since {
|
||||||
|
Some(date_since) => {
|
||||||
|
fetch_and_save_by_date(
|
||||||
|
account,
|
||||||
|
date_since.since_date()?.as_str(),
|
||||||
|
remote_mailbox,
|
||||||
|
FetchDirection::Since,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
143
crates/core/src/cache/imap/download/mod.rs
vendored
Normal file
143
crates/core/src/cache/imap/download/mod.rs
vendored
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
account::{
|
||||||
|
migration::{AccountModel, AccountType},
|
||||||
|
state::{DownloadState, DownloadStatus, TriggerType},
|
||||||
|
},
|
||||||
|
cache::imap::{download::flow::FetchDirection, mailbox::MailBox},
|
||||||
|
error::BichonResult,
|
||||||
|
imap::executor::ImapExecutor,
|
||||||
|
};
|
||||||
|
use download_folders::get_download_folders;
|
||||||
|
use download_type::{decide_next_download_task, DownloadTask};
|
||||||
|
use flow::reconcile_mailboxes;
|
||||||
|
use rebuild::{rebuild_cache, rebuild_cache_by_date};
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
pub mod download_folders;
|
||||||
|
pub mod download_type;
|
||||||
|
pub mod flow;
|
||||||
|
pub mod rebuild;
|
||||||
|
|
||||||
|
pub async fn process_imap_download(
|
||||||
|
account: &AccountModel,
|
||||||
|
token: CancellationToken,
|
||||||
|
trigger_type: TriggerType,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
assert_eq!(account.account_type, AccountType::IMAP);
|
||||||
|
let start_time = Instant::now();
|
||||||
|
let account_id = account.id;
|
||||||
|
let download_task = decide_next_download_task(account, trigger_type).await?;
|
||||||
|
if matches!(download_task, DownloadTask::Idle) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut session = match ImapExecutor::create_connection(account_id).await {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Failed to connect to IMAP server: {:#?}", e);
|
||||||
|
DownloadState::append_session_error(account_id, err_msg.clone())?;
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account_id,
|
||||||
|
DownloadStatus::Failed,
|
||||||
|
Some(err_msg),
|
||||||
|
)?;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let remote_mailboxes = match get_download_folders(account, &mut session).await {
|
||||||
|
Ok(mailboxes) => mailboxes,
|
||||||
|
Err(err) => {
|
||||||
|
let err_msg = format!("Failed to fetch mailboxes: {:#?}", err);
|
||||||
|
warn!(account_id = account.id, error = %err, "{}", err_msg);
|
||||||
|
DownloadState::append_session_error(account_id, err_msg.clone())?;
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account_id,
|
||||||
|
DownloadStatus::Failed,
|
||||||
|
Some(err_msg),
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
session.logout().await.ok();
|
||||||
|
if matches!(download_task, DownloadTask::FullFetch) {
|
||||||
|
let result = match &account.date_since {
|
||||||
|
Some(date_since) => {
|
||||||
|
rebuild_cache_by_date(
|
||||||
|
account,
|
||||||
|
&remote_mailboxes,
|
||||||
|
&date_since.since_date()?,
|
||||||
|
FetchDirection::Since,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => match &account.date_before {
|
||||||
|
Some(r) => {
|
||||||
|
rebuild_cache_by_date(
|
||||||
|
account,
|
||||||
|
&remote_mailboxes,
|
||||||
|
&r.calculate_date()?,
|
||||||
|
FetchDirection::Before,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => rebuild_cache(account, &remote_mailboxes, token).await,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
DownloadState::update_session_status(account_id, DownloadStatus::Success, None)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Email Download interrupted: {:#?}", e);
|
||||||
|
DownloadState::append_session_error(account_id, err_msg.clone())?;
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account_id,
|
||||||
|
DownloadStatus::Failed,
|
||||||
|
Some(err_msg),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_mailboxes = MailBox::list_all(account_id)?;
|
||||||
|
match reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes, token).await {
|
||||||
|
Ok(_) => DownloadState::update_session_status(account_id, DownloadStatus::Success, None)?,
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Email Download interrupted: {:#?}", e);
|
||||||
|
DownloadState::append_session_error(account_id, err_msg.clone())?;
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account_id,
|
||||||
|
DownloadStatus::Failed,
|
||||||
|
Some(err_msg),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let elapsed_time = start_time.elapsed().as_secs();
|
||||||
|
debug!(
|
||||||
|
"Account{{{}}} Incremental sync completed: {} seconds elapsed.",
|
||||||
|
account.email, elapsed_time
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
259
crates/core/src/cache/imap/download/rebuild.rs
vendored
Normal file
259
crates/core/src/cache/imap/download/rebuild.rs
vendored
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
raise_error,
|
||||||
|
{
|
||||||
|
account::{
|
||||||
|
migration::AccountModel,
|
||||||
|
state::{DownloadState, DownloadStatus, FolderStatus},
|
||||||
|
},
|
||||||
|
cache::{
|
||||||
|
imap::{
|
||||||
|
download::flow::{
|
||||||
|
fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection,
|
||||||
|
},
|
||||||
|
mailbox::MailBox,
|
||||||
|
},
|
||||||
|
SEMAPHORE,
|
||||||
|
},
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
pub async fn rebuild_cache(
|
||||||
|
account: &AccountModel,
|
||||||
|
remote_mailboxes: &[MailBox],
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
MailBox::batch_insert(remote_mailboxes)?;
|
||||||
|
DownloadState::init_folder_details(
|
||||||
|
account.id,
|
||||||
|
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut has_error = false;
|
||||||
|
let mut last_err = None;
|
||||||
|
|
||||||
|
for mailbox in remote_mailboxes {
|
||||||
|
if token.is_cancelled() {
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account.id,
|
||||||
|
DownloadStatus::Cancelled,
|
||||||
|
Some("Received termination signal (User stop or System shutdown)".to_string()),
|
||||||
|
)?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if mailbox.exists == 0 {
|
||||||
|
info!(
|
||||||
|
"Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.",
|
||||||
|
account.id, &mailbox.name
|
||||||
|
);
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Success,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let account = account.clone();
|
||||||
|
let mailbox = mailbox.clone();
|
||||||
|
|
||||||
|
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
|
||||||
|
account.id, &mailbox.name, err
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => {
|
||||||
|
has_error = true;
|
||||||
|
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||||
|
last_err = Some(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_error {
|
||||||
|
if let Some(e) = last_err {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Some tasks failed".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rebuild_cache_by_date(
|
||||||
|
account: &AccountModel,
|
||||||
|
remote_mailboxes: &[MailBox],
|
||||||
|
date: &str,
|
||||||
|
direction: FetchDirection,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
MailBox::batch_insert(remote_mailboxes)?;
|
||||||
|
DownloadState::init_folder_details(
|
||||||
|
account.id,
|
||||||
|
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut has_error = false;
|
||||||
|
let mut last_err = None;
|
||||||
|
|
||||||
|
for mailbox in remote_mailboxes {
|
||||||
|
if token.is_cancelled() {
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account.id,
|
||||||
|
DownloadStatus::Cancelled,
|
||||||
|
Some("Received termination signal (User stop or System shutdown)".to_string()),
|
||||||
|
)?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if mailbox.exists == 0 {
|
||||||
|
info!(
|
||||||
|
"Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.",
|
||||||
|
account.id, &mailbox.name
|
||||||
|
);
|
||||||
|
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Success,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let account = account.clone();
|
||||||
|
let mailbox = mailbox.clone();
|
||||||
|
let date = date.to_string();
|
||||||
|
let direction = direction.clone();
|
||||||
|
|
||||||
|
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
|
||||||
|
account.id, &mailbox.name, err
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction, token.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => {
|
||||||
|
has_error = true;
|
||||||
|
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||||
|
last_err = Some(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_error {
|
||||||
|
if let Some(e) = last_err {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Some tasks failed".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rebuild_mailbox_cache(
|
||||||
|
account: &AccountModel,
|
||||||
|
local_mailbox: &MailBox,
|
||||||
|
remote_mailbox: &MailBox,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
ENVELOPE_MANAGER
|
||||||
|
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if remote_mailbox.exists == 0 {
|
||||||
|
info!(
|
||||||
|
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
|
||||||
|
account.id,
|
||||||
|
&local_mailbox.name
|
||||||
|
);
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
remote_mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Success,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rebuild_mailbox_cache_by_date(
|
||||||
|
account: &AccountModel,
|
||||||
|
local_mailbox_id: u64,
|
||||||
|
date: &str,
|
||||||
|
remote: &MailBox,
|
||||||
|
direction: FetchDirection,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
ENVELOPE_MANAGER
|
||||||
|
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
|
||||||
|
.await?;
|
||||||
|
if remote.exists == 0 {
|
||||||
|
info!(
|
||||||
|
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
|
||||||
|
account.id,
|
||||||
|
&remote.name
|
||||||
|
);
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
remote.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Success,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_and_save_by_date(account, date, remote, direction, token).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -17,32 +17,24 @@
|
|||||||
// 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, raise_error,
|
||||||
modules::{
|
{
|
||||||
database::{
|
database::{
|
||||||
async_find_impl, batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl,
|
batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl, filter_impl,
|
||||||
filter_by_secondary_key_impl, manager::DB_MANAGER,
|
find_impl, manager::DB_MANAGER, MemDbModel,
|
||||||
},
|
},
|
||||||
error::{code::ErrorCode, BichonResult},
|
error::{code::ErrorCode, BichonResult},
|
||||||
},
|
},
|
||||||
raise_error,
|
|
||||||
};
|
};
|
||||||
use async_imap::types::{Name, NameAttribute};
|
use async_imap::types::{Name, NameAttribute};
|
||||||
use itertools::Itertools;
|
|
||||||
use native_db::*;
|
|
||||||
use native_model::{native_model, Model};
|
|
||||||
use poem_openapi::{Enum, Object};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
#[native_model(id = 1, version = 1)]
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
#[native_db]
|
|
||||||
pub struct MailBox {
|
pub struct MailBox {
|
||||||
/// The unique identifier for the mailbox
|
/// The unique identifier for the mailbox
|
||||||
#[primary_key]
|
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
/// The ID of the account associated with the mailbox
|
/// The ID of the account associated with the mailbox
|
||||||
#[secondary_key]
|
|
||||||
pub account_id: u64,
|
pub account_id: u64,
|
||||||
/// The unique, decoded, human-readable name of the mailbox (e.g., "INBOX", "Sent Items").
|
/// The unique, decoded, human-readable name of the mailbox (e.g., "INBOX", "Sent Items").
|
||||||
/// This is the decoded name as presented to users, derived from the IMAP server's mailbox name
|
/// This is the decoded name as presented to users, derived from the IMAP server's mailbox name
|
||||||
@@ -66,31 +58,22 @@ pub struct MailBox {
|
|||||||
pub uid_validity: Option<u32>,
|
pub uid_validity: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MemDbModel for MailBox {
|
||||||
|
fn collection() -> &'static str {
|
||||||
|
"mailboxes"
|
||||||
|
}
|
||||||
|
fn key(&self) -> String {
|
||||||
|
self.id.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl MailBox {
|
impl MailBox {
|
||||||
pub fn encoded_name(&self) -> String {
|
pub fn encoded_name(&self) -> String {
|
||||||
encode_mailbox_name!(&self.name)
|
encode_mailbox_name!(&self.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pub async fn batch_delete(mailboxes: Vec<MailBox>) -> BichonResult<()> {
|
pub fn get(id: u64) -> BichonResult<MailBox> {
|
||||||
// batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
let result = find_impl::<MailBox>(DB_MANAGER.db(), &id.to_string())?;
|
||||||
// let mut to_deleted = Vec::new();
|
|
||||||
// for mailbox in mailboxes {
|
|
||||||
// let retrived = rw
|
|
||||||
// .get()
|
|
||||||
// .primary::<MailBox>(mailbox.id)
|
|
||||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
|
||||||
// if let Some(retrived) = retrived {
|
|
||||||
// to_deleted.push(retrived);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// Ok(to_deleted)
|
|
||||||
// })
|
|
||||||
// .await?;
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
|
|
||||||
pub async fn get(id: u64) -> BichonResult<MailBox> {
|
|
||||||
let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
|
|
||||||
Ok(result.ok_or_else(|| {
|
Ok(result.ok_or_else(|| {
|
||||||
raise_error!(
|
raise_error!(
|
||||||
format!("mailbox {} not found", id),
|
format!("mailbox {} not found", id),
|
||||||
@@ -99,47 +82,40 @@ impl MailBox {
|
|||||||
})?)
|
})?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(id: u64) -> BichonResult<()> {
|
pub fn delete(id: u64) -> BichonResult<()> {
|
||||||
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
delete_impl::<MailBox>(DB_MANAGER.db(), &id.to_string())
|
||||||
rw.get()
|
|
||||||
.primary::<MailBox>(id)
|
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
|
||||||
.ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
|
pub fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
|
||||||
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id)
|
filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
pub fn find_mailbox(account_id: u64, mailbox_id: u64) -> BichonResult<Option<MailBox>> {
|
||||||
batch_insert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
|
let all = filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)?;
|
||||||
|
Ok(all.into_iter().find(|m| m.id == mailbox_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
pub fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
||||||
batch_upsert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
|
batch_insert_impl(DB_MANAGER.db(), mailboxes.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clean(account_id: u64) -> BichonResult<()> {
|
pub fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
||||||
batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
batch_upsert_impl(DB_MANAGER.db(), mailboxes.to_vec())
|
||||||
let mailboxes: Vec<MailBox> = rw
|
}
|
||||||
.scan()
|
|
||||||
.secondary::<MailBox>(MailBoxKey::account_id)
|
pub fn clean(account_id: u64) -> BichonResult<()> {
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
let mailboxes =
|
||||||
.start_with(account_id)
|
filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)?;
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
let keys: Vec<String> = mailboxes.iter().map(|m| m.id.to_string()).collect();
|
||||||
.try_collect()
|
if !keys.is_empty() {
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
batch_delete_impl::<MailBox>(DB_MANAGER.db(), keys)?;
|
||||||
Ok(mailboxes)
|
}
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, Object)]
|
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct Attribute {
|
pub struct Attribute {
|
||||||
pub attr: AttributeEnum,
|
pub attr: AttributeEnum,
|
||||||
pub extension: Option<String>,
|
pub extension: Option<String>,
|
||||||
@@ -151,7 +127,8 @@ impl Attribute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, Enum)]
|
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
pub enum AttributeEnum {
|
pub enum AttributeEnum {
|
||||||
NoInferiors,
|
NoInferiors,
|
||||||
NoSelect,
|
NoSelect,
|
||||||
114
crates/core/src/cache/imap/mailbox_cache.rs
vendored
Normal file
114
crates/core/src/cache/imap/mailbox_cache.rs
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::cache::imap::mailbox::MailBox;
|
||||||
|
use crate::utc_now;
|
||||||
|
use lru::LruCache;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
struct CacheEntry {
|
||||||
|
mailboxes: Vec<MailBox>,
|
||||||
|
fetched_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
static CACHE: LazyLock<Mutex<LruCache<u64, CacheEntry>>> = LazyLock::new(|| {
|
||||||
|
Mutex::new(LruCache::new(NonZeroUsize::new(64).unwrap()))
|
||||||
|
});
|
||||||
|
|
||||||
|
const TTL_MS: i64 = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
pub async fn get(account_id: u64) -> Option<Vec<MailBox>> {
|
||||||
|
let mut guard = CACHE.lock().await;
|
||||||
|
if let Some(entry) = guard.get(&account_id) {
|
||||||
|
if utc_now!() - entry.fetched_at < TTL_MS {
|
||||||
|
return Some(entry.mailboxes.clone());
|
||||||
|
}
|
||||||
|
guard.pop(&account_id);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set(account_id: u64, mailboxes: Vec<MailBox>) {
|
||||||
|
let mut guard = CACHE.lock().await;
|
||||||
|
guard.put(
|
||||||
|
account_id,
|
||||||
|
CacheEntry {
|
||||||
|
mailboxes,
|
||||||
|
fetched_at: utc_now!(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invalidate(account_id: u64) {
|
||||||
|
let mut guard = CACHE.lock().await;
|
||||||
|
guard.pop(&account_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background fetch state tracking
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum FetchStatus {
|
||||||
|
Fetching { examined: usize, total: usize },
|
||||||
|
Ready,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
static FETCH_STATES: LazyLock<Mutex<HashMap<u64, FetchStatus>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
pub async fn fetch_status(account_id: u64) -> Option<FetchStatus> {
|
||||||
|
FETCH_STATES.lock().await.get(&account_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_fetching(account_id: u64) {
|
||||||
|
FETCH_STATES.lock().await.insert(
|
||||||
|
account_id,
|
||||||
|
FetchStatus::Fetching {
|
||||||
|
examined: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_fetch_progress(account_id: u64, examined: usize, total: usize) {
|
||||||
|
let mut guard = FETCH_STATES.lock().await;
|
||||||
|
guard.insert(
|
||||||
|
account_id,
|
||||||
|
FetchStatus::Fetching { examined, total },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_fetch_ready(account_id: u64) {
|
||||||
|
FETCH_STATES
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(account_id, FetchStatus::Ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_fetch_error(account_id: u64, error: String) {
|
||||||
|
FETCH_STATES
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(account_id, FetchStatus::Error(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_fetch_state(account_id: u64) {
|
||||||
|
FETCH_STATES.lock().await.remove(&account_id);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,30 +16,20 @@
|
|||||||
// 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 std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
use crate::modules::{account::state::AccountRunningState, database::ModelsAdapter};
|
|
||||||
use ahash::{AHashMap, AHashSet};
|
|
||||||
use mailbox::MailBox;
|
use mailbox::MailBox;
|
||||||
use native_db::Models;
|
|
||||||
|
|
||||||
|
pub mod download;
|
||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
pub mod sync;
|
pub mod mailbox_cache;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
|
||||||
pub static MAILBOX_MODELS: LazyLock<Models> = LazyLock::new(|| {
|
|
||||||
let mut adapter = ModelsAdapter::new();
|
|
||||||
adapter.register_model::<MailBox>();
|
|
||||||
adapter.register_model::<AccountRunningState>();
|
|
||||||
adapter.models
|
|
||||||
});
|
|
||||||
|
|
||||||
pub fn find_missing_mailboxes(
|
pub fn find_missing_mailboxes(
|
||||||
local_mailboxes: &[MailBox],
|
local_mailboxes: &[MailBox],
|
||||||
server_mailboxes: &[MailBox],
|
server_mailboxes: &[MailBox],
|
||||||
) -> Vec<MailBox> {
|
) -> Vec<MailBox> {
|
||||||
let local_names: AHashSet<_> = local_mailboxes.iter().map(|m| &m.name).collect();
|
let local_names: HashSet<_> = local_mailboxes.iter().map(|m| &m.name).collect();
|
||||||
server_mailboxes
|
server_mailboxes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| !local_names.contains(&m.name))
|
.filter(|m| !local_names.contains(&m.name))
|
||||||
@@ -51,7 +41,7 @@ pub fn find_intersecting_mailboxes(
|
|||||||
local_mailboxes: &[MailBox],
|
local_mailboxes: &[MailBox],
|
||||||
remote_mailboxes: &[MailBox],
|
remote_mailboxes: &[MailBox],
|
||||||
) -> Vec<(MailBox, MailBox)> {
|
) -> Vec<(MailBox, MailBox)> {
|
||||||
let local_map: AHashMap<_, _> = local_mailboxes
|
let local_map: HashMap<_, _> = local_mailboxes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| (m.name.clone(), m.clone()))
|
.map(|m| (m.name.clone(), m.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
276
crates/core/src/cache/imap/task.rs
vendored
Normal file
276
crates/core/src/cache/imap/task.rs
vendored
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::account::entity::AuthType;
|
||||||
|
use crate::account::state::{DownloadState, TriggerType};
|
||||||
|
use crate::cache::imap::download::process_imap_download;
|
||||||
|
use crate::common::periodic::{PeriodicTask, TaskHandle};
|
||||||
|
use crate::error::code::ErrorCode;
|
||||||
|
use crate::oauth2::token::OAuth2AccessToken;
|
||||||
|
use crate::{account::migration::AccountModel, error::BichonResult};
|
||||||
|
use crate::{raise_error, utc_now};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use std::{sync::LazyLock, time::Duration};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
static _DESCRIPTION: &str = "This task periodically synchronizes mailbox data for a specified account, ensuring that all local data is up-to-date.";
|
||||||
|
const TASK_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
pub static SYNC_TASKS: LazyLock<AccountDownTask> = LazyLock::new(AccountDownTask::new);
|
||||||
|
static LAST_WARN_TIME: AtomicI64 = AtomicI64::new(0);
|
||||||
|
const WARN_INTERVAL_MS: i64 = 600_000;
|
||||||
|
|
||||||
|
pub struct AccountDownTask {
|
||||||
|
tasks: Mutex<Option<HashMap<u64, (TaskHandle, CancellationToken)>>>,
|
||||||
|
manual_tasks: Mutex<HashMap<u64, (JoinHandle<()>, CancellationToken)>>,
|
||||||
|
busy_accounts: Mutex<HashSet<u64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountDownTask {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tasks: Mutex::new(Some(HashMap::new())),
|
||||||
|
manual_tasks: Mutex::new(HashMap::new()),
|
||||||
|
busy_accounts: Mutex::new(HashSet::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_busy(&self, account_id: u64, is_busy: bool) {
|
||||||
|
let mut guard = self.busy_accounts.lock().await;
|
||||||
|
if is_busy {
|
||||||
|
guard.insert(account_id);
|
||||||
|
} else {
|
||||||
|
guard.remove(&account_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically check and set busy. Returns true if we claimed the slot,
|
||||||
|
/// false if another task is already busy on this account.
|
||||||
|
async fn try_set_busy(&self, account_id: u64) -> bool {
|
||||||
|
let mut guard = self.busy_accounts.lock().await;
|
||||||
|
if guard.contains(&account_id) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
guard.insert(account_id);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// async fn is_busy(&self, account_id: u64) -> bool {
|
||||||
|
// self.busy_accounts.lock().await.contains(&account_id)
|
||||||
|
// }
|
||||||
|
|
||||||
|
pub async fn start_download_task(&self, account_id: u64, email: String) {
|
||||||
|
let task_name = format!("account-download-task-{}-{}", account_id, &email);
|
||||||
|
let periodic_task = PeriodicTask::new(&task_name);
|
||||||
|
|
||||||
|
let cancel_token = CancellationToken::new();
|
||||||
|
let task_token = cancel_token.clone();
|
||||||
|
|
||||||
|
let task = move |param: Option<u64>| {
|
||||||
|
let account_id = param.unwrap();
|
||||||
|
let internal_token = task_token.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
if SYNC_TASKS.is_manual_running(account_id).await {
|
||||||
|
info!(
|
||||||
|
"Account {}: Scheduled task skipped (Manual task is running).",
|
||||||
|
account_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !SYNC_TASKS.try_set_busy(account_id).await {
|
||||||
|
warn!(
|
||||||
|
"Account {}: Scheduled task skipped (Previous sync still active).",
|
||||||
|
account_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let _busy_guard = scopeguard::guard(account_id, |id| {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
SYNC_TASKS.set_busy(id, false).await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let account = AccountModel::get(account_id).ok();
|
||||||
|
match account {
|
||||||
|
Some(account) => {
|
||||||
|
if !account.enabled {
|
||||||
|
let last = LAST_WARN_TIME.load(Ordering::Relaxed);
|
||||||
|
let now = utc_now!();
|
||||||
|
if now - last >= WARN_INTERVAL_MS {
|
||||||
|
LAST_WARN_TIME.store(now, Ordering::Relaxed);
|
||||||
|
warn!(
|
||||||
|
"Account {}: download aborted. Account is currently disabled.",
|
||||||
|
account_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(imap) = &account.imap {
|
||||||
|
if let AuthType::OAuth2 = imap.auth.auth_type {
|
||||||
|
if OAuth2AccessToken::get(account.id)?.is_none() {
|
||||||
|
if utc_now!() % 300_000 == 0 {
|
||||||
|
warn!("Account {}: download aborted. OAuth2 authorization not completed. Please visit the rustmailer admin page to authorize this account.", account_id);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = process_imap_download(
|
||||||
|
&account,
|
||||||
|
internal_token,
|
||||||
|
TriggerType::Scheduled,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
DownloadState::append_session_error(
|
||||||
|
account.id,
|
||||||
|
format!("error in account download task: {:#?}", e),
|
||||||
|
)?;
|
||||||
|
error!(
|
||||||
|
"Failed to download mailbox data for '{}': {:?}",
|
||||||
|
account_id, e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
error!(
|
||||||
|
"Account {}: download aborted. Account entity not found.",
|
||||||
|
account_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let handler = periodic_task.start(task, Some(account_id), TASK_INTERVAL, true, true);
|
||||||
|
self.add_task(account_id, (handler, cancel_token)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_task(&self, account_id: u64, handler: (TaskHandle, CancellationToken)) {
|
||||||
|
let mut guard = self.tasks.lock().await;
|
||||||
|
if let Some(map) = guard.as_mut() {
|
||||||
|
map.insert(account_id, handler);
|
||||||
|
} else {
|
||||||
|
tracing::error!("Failed to add task: HashMap has been taken during shutdown.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self, account_id: u64) -> BichonResult<()> {
|
||||||
|
let mut guard = self.tasks.lock().await;
|
||||||
|
if let Some(map) = guard.as_mut() {
|
||||||
|
if let Some((handler, token)) = map.remove(&account_id) {
|
||||||
|
drop(guard);
|
||||||
|
token.cancel();
|
||||||
|
handler.cancel().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn shutdown(&self) {
|
||||||
|
let mut guard = self.tasks.lock().await;
|
||||||
|
if let Some(map) = guard.take() {
|
||||||
|
drop(guard);
|
||||||
|
for (account_id, (handler, token)) in map {
|
||||||
|
info!(
|
||||||
|
"Shutdown: Sending cancel signal to account {}...",
|
||||||
|
account_id
|
||||||
|
);
|
||||||
|
token.cancel();
|
||||||
|
if let Err(_) = tokio::time::timeout(Duration::from_secs(5), handler.stop()).await {
|
||||||
|
error!(
|
||||||
|
"Shutdown: Account {} download task forced timeout.",
|
||||||
|
account_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Shutdown: All download tasks processed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_manual_task(&self, account_id: u64) -> BichonResult<()> {
|
||||||
|
{
|
||||||
|
if self.is_manual_running(account_id).await {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Manual task already running.".into(),
|
||||||
|
ErrorCode::Forbidden
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !self.try_set_busy(account_id).await {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"The background synchronization is currently active. Please try again in a few seconds.".into(),
|
||||||
|
ErrorCode::Forbidden
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancel_token = CancellationToken::new();
|
||||||
|
let token_clone = cancel_token.clone();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
// busy already claimed by caller via try_set_busy
|
||||||
|
let _cleanup = scopeguard::guard(account_id, |id| {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
SYNC_TASKS.set_busy(id, false).await;
|
||||||
|
let mut guard = SYNC_TASKS.manual_tasks.lock().await;
|
||||||
|
guard.remove(&id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if token_clone.is_cancelled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let account = match AccountModel::get(account_id) {
|
||||||
|
Ok(acc) => acc,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to fetch account {}: {:?}", account_id, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = process_imap_download(&account, token_clone, TriggerType::Manual).await
|
||||||
|
{
|
||||||
|
error!("Manual download failed for {}: {:?}", account_id, e);
|
||||||
|
let error_msg = format!("error in account download task: {:#?}", e);
|
||||||
|
let _ = DownloadState::append_session_error(account.id, error_msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let mut guard = self.manual_tasks.lock().await;
|
||||||
|
guard.insert(account_id, (handle, cancel_token));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_manual_task(&self, account_id: u64) {
|
||||||
|
let mut guard = self.manual_tasks.lock().await;
|
||||||
|
if let Some((handle, token)) = guard.remove(&account_id) {
|
||||||
|
token.cancel();
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_manual_running(&self, account_id: u64) -> bool {
|
||||||
|
let guard = self.manual_tasks.lock().await;
|
||||||
|
guard.contains_key(&account_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
// 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::settings::cli::SETTINGS;
|
use crate::settings::cli::SETTINGS;
|
||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
143
crates/core/src/common/auth.rs
Normal file
143
crates/core/src/common/auth.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
use std::{
|
||||||
|
collections::{BTreeSet, HashSet},
|
||||||
|
net::IpAddr,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
raise_error,
|
||||||
|
users::{permissions::Permission, role::UserRole, UserModel},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ClientContext {
|
||||||
|
pub ip_addr: Option<IpAddr>,
|
||||||
|
pub user: UserModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientContext {
|
||||||
|
pub fn require_any_permission(
|
||||||
|
&self,
|
||||||
|
requirements: Vec<(Option<u64>, &str)>,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
for (account_id, permission) in requirements {
|
||||||
|
if self.has_permission(account_id, permission) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(raise_error!(
|
||||||
|
"Access denied: Insufficient permissions to perform this action.".into(),
|
||||||
|
ErrorCode::Forbidden
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_has_permission(
|
||||||
|
user: &UserModel,
|
||||||
|
account_id: Option<u64>,
|
||||||
|
permission: &str,
|
||||||
|
) -> bool {
|
||||||
|
if user.is_admin() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut global_perms = HashSet::new();
|
||||||
|
for rid in &user.global_roles {
|
||||||
|
if let Some(role) = UserRole::find(*rid).ok().flatten() {
|
||||||
|
global_perms.extend(role.permissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if Self::check_global_logic(&global_perms, permission) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(aid) = account_id {
|
||||||
|
if let Some(role_id) = user.account_access_map.get(&aid) {
|
||||||
|
if let Some(role) = UserRole::find(*role_id).ok().flatten() {
|
||||||
|
if role.permissions.contains(&permission.to_string())
|
||||||
|
|| Self::check_account_logic(&role.permissions, permission)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
|
||||||
|
if self.user.is_admin() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut global_perms = HashSet::new();
|
||||||
|
for rid in &self.user.global_roles {
|
||||||
|
if let Some(role) = UserRole::find(*rid).ok().flatten() {
|
||||||
|
global_perms.extend(role.permissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if Self::check_global_logic(&global_perms, permission) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(aid) = account_id {
|
||||||
|
if let Some(role_id) = self.user.account_access_map.get(&aid) {
|
||||||
|
if let Some(role) = UserRole::find(*role_id).ok().flatten() {
|
||||||
|
if role.permissions.contains(&permission.to_string())
|
||||||
|
|| Self::check_account_logic(&role.permissions, permission)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_global_logic(global: &HashSet<String>, perm: &str) -> bool {
|
||||||
|
if global.contains(perm) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match perm {
|
||||||
|
Permission::DATA_READ => global.contains(Permission::DATA_READ_ALL),
|
||||||
|
Permission::DATA_DELETE => global.contains(Permission::DATA_DELETE_ALL),
|
||||||
|
Permission::DATA_RAW_DOWNLOAD => global.contains(Permission::DATA_RAW_DOWNLOAD_ALL),
|
||||||
|
Permission::DATA_EXPORT_BATCH => global.contains(Permission::DATA_EXPORT_BATCH_ALL),
|
||||||
|
Permission::ACCOUNT_MANAGE | Permission::ACCOUNT_READ_DETAILS => {
|
||||||
|
global.contains(Permission::ACCOUNT_MANAGE_ALL)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_account_logic(scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
|
||||||
|
if scoped_perms.contains(perm) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match perm {
|
||||||
|
Permission::DATA_READ | Permission::ACCOUNT_READ_DETAILS => {
|
||||||
|
scoped_perms.contains(Permission::ACCOUNT_MANAGE)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_permission(
|
||||||
|
&self,
|
||||||
|
account_id: Option<u64>,
|
||||||
|
permission: &str,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
if self.has_permission(account_id, permission) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(raise_error!(
|
||||||
|
format!("Access Denied: Missing permission '{}'", permission),
|
||||||
|
ErrorCode::Forbidden
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,29 +16,19 @@
|
|||||||
// 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 super::error::code::ErrorCode;
|
|
||||||
use super::error::BichonError;
|
|
||||||
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
|
|
||||||
use poem::error::ResponseError;
|
|
||||||
use poem::Body;
|
|
||||||
use poem::{http::StatusCode, Error, Response};
|
|
||||||
use poem_openapi::Object;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use tracing::error;
|
|
||||||
|
|
||||||
|
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod error;
|
|
||||||
pub mod log;
|
|
||||||
pub mod paginated;
|
pub mod paginated;
|
||||||
pub mod periodic;
|
pub mod periodic;
|
||||||
pub mod rustls;
|
pub mod rustls;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod timeout;
|
#[cfg(feature = "web-api")]
|
||||||
pub mod tls;
|
|
||||||
pub mod validator;
|
pub mod validator;
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Object)]
|
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||||
pub struct Addr {
|
pub struct Addr {
|
||||||
/// The optional display name associated with the email address (e.g., "John Doe").
|
/// The optional display name associated with the email address (e.g., "John Doe").
|
||||||
/// If `None`, no display name is specified.
|
/// If `None`, no display name is specified.
|
||||||
@@ -91,62 +81,3 @@ impl<'x> From<&ImapAddress<'x>> for AddrVec {
|
|||||||
AddrVec(vec)
|
AddrVec(vec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #[derive(Serialize)]
|
|
||||||
// pub struct ErrorResponse {
|
|
||||||
// pub message: String,
|
|
||||||
// }
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn create_rust_mailer_error(message: &str, code: ErrorCode) -> BichonError {
|
|
||||||
BichonError::Generic {
|
|
||||||
message: message.into(),
|
|
||||||
location: snafu::Location::default(),
|
|
||||||
code,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn create_api_error_response(message: &str, code: ErrorCode) -> Error {
|
|
||||||
let rust_mailer_error = create_rust_mailer_error(message, code);
|
|
||||||
rust_mailer_error.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResponseError for BichonError {
|
|
||||||
fn status(&self) -> StatusCode {
|
|
||||||
match self {
|
|
||||||
BichonError::Generic {
|
|
||||||
message: _,
|
|
||||||
location: _,
|
|
||||||
code,
|
|
||||||
} => code.status(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_response(&self) -> Response
|
|
||||||
where
|
|
||||||
Self: std::error::Error + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
match self {
|
|
||||||
BichonError::Generic {
|
|
||||||
message,
|
|
||||||
location,
|
|
||||||
code,
|
|
||||||
} => {
|
|
||||||
error!(
|
|
||||||
error_code = *code as u32,
|
|
||||||
error_message = %message,
|
|
||||||
error_location = ?location
|
|
||||||
);
|
|
||||||
|
|
||||||
let body = Body::from_json(serde_json::json!({
|
|
||||||
"code": *code as u32,
|
|
||||||
"message": message.to_string(),
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
Response::builder().status(self.status()).body(body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
260
crates/core/src/common/paginated.rs
Normal file
260
crates/core/src/common/paginated.rs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
raise_error,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cmp::min;
|
||||||
|
|
||||||
|
pub fn paginate_vec<T: Clone>(
|
||||||
|
items: &Vec<T>,
|
||||||
|
page: Option<u64>,
|
||||||
|
page_size: Option<u64>,
|
||||||
|
) -> BichonResult<Paginated<T>> {
|
||||||
|
let total_items = items.len() as u64;
|
||||||
|
|
||||||
|
let (offset, total_pages) = match (page, page_size) {
|
||||||
|
(Some(p), Some(s)) if p > 0 && s > 0 => {
|
||||||
|
let offset = (p - 1) * s;
|
||||||
|
let total_pages = if total_items > 0 {
|
||||||
|
(total_items + s - 1) / s
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
(Some(offset), Some(total_pages))
|
||||||
|
}
|
||||||
|
(Some(0), _) | (_, Some(0)) => {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"'page' and 'page_size' must be greater than 0.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = match offset {
|
||||||
|
Some(offset) if offset >= total_items => vec![],
|
||||||
|
Some(offset) => {
|
||||||
|
let end = min(offset + page_size.unwrap_or(total_items), total_items) as usize;
|
||||||
|
items[offset as usize..end].to_vec()
|
||||||
|
}
|
||||||
|
None => items.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Paginated::new(
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_items,
|
||||||
|
total_pages,
|
||||||
|
data,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(not(feature = "web-api"))]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DataPage<S>
|
||||||
|
where
|
||||||
|
S: Serialize + std::fmt::Debug + std::marker::Unpin + Send + Sync,
|
||||||
|
{
|
||||||
|
/// The current page number (starting from 1).
|
||||||
|
pub current_page: Option<u64>,
|
||||||
|
/// The number of items per page.
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
/// The total number of items across all pages.
|
||||||
|
pub total_items: u64,
|
||||||
|
/// The list of items returned on the current page.
|
||||||
|
pub items: Vec<S>,
|
||||||
|
/// The total number of pages. This is optional and may not be set if not calculated.
|
||||||
|
pub total_pages: Option<u64>,
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "web-api"))]
|
||||||
|
impl<S: Serialize + std::fmt::Debug + std::marker::Unpin + Send + Sync> From<Paginated<S>>
|
||||||
|
for DataPage<S>
|
||||||
|
{
|
||||||
|
fn from(paginated: Paginated<S>) -> Self {
|
||||||
|
DataPage {
|
||||||
|
current_page: paginated.page,
|
||||||
|
page_size: paginated.page_size,
|
||||||
|
total_items: paginated.total_items,
|
||||||
|
total_pages: paginated.total_pages,
|
||||||
|
items: paginated.items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(feature = "web-api")]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, poem_openapi::Object)]
|
||||||
|
pub struct DataPage<S>
|
||||||
|
where
|
||||||
|
S: Serialize
|
||||||
|
+ std::fmt::Debug
|
||||||
|
+ std::marker::Unpin
|
||||||
|
+ Send
|
||||||
|
+ Sync
|
||||||
|
+ poem_openapi::types::Type
|
||||||
|
+ poem_openapi::types::ParseFromJSON
|
||||||
|
+ poem_openapi::types::ToJSON,
|
||||||
|
{
|
||||||
|
/// The current page number (starting from 1).
|
||||||
|
pub current_page: Option<u64>,
|
||||||
|
/// The number of items per page.
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
/// The total number of items across all pages.
|
||||||
|
pub total_items: u64,
|
||||||
|
/// The list of items returned on the current page.
|
||||||
|
pub items: Vec<S>,
|
||||||
|
/// The total number of pages. This is optional and may not be set if not calculated.
|
||||||
|
pub total_pages: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web-api")]
|
||||||
|
impl<
|
||||||
|
S: Serialize
|
||||||
|
+ std::fmt::Debug
|
||||||
|
+ std::marker::Unpin
|
||||||
|
+ Send
|
||||||
|
+ Sync
|
||||||
|
+ poem_openapi::types::Type
|
||||||
|
+ poem_openapi::types::ParseFromJSON
|
||||||
|
+ poem_openapi::types::ToJSON,
|
||||||
|
> From<Paginated<S>> for DataPage<S>
|
||||||
|
{
|
||||||
|
fn from(paginated: Paginated<S>) -> Self {
|
||||||
|
DataPage {
|
||||||
|
current_page: paginated.page,
|
||||||
|
page_size: paginated.page_size,
|
||||||
|
total_items: paginated.total_items,
|
||||||
|
total_pages: paginated.total_pages,
|
||||||
|
items: paginated.items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Paginated<T> {
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
pub total_items: u64,
|
||||||
|
pub total_pages: Option<u64>,
|
||||||
|
pub items: Vec<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Paginated<T> {
|
||||||
|
pub fn new(
|
||||||
|
page: Option<u64>,
|
||||||
|
page_size: Option<u64>,
|
||||||
|
total_items: u64,
|
||||||
|
total_pages: Option<u64>,
|
||||||
|
items: Vec<T>,
|
||||||
|
) -> Self {
|
||||||
|
Paginated {
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_items,
|
||||||
|
total_pages,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginate_vec_full_list_without_pagination() {
|
||||||
|
let items: Vec<i32> = (1..=10).collect();
|
||||||
|
let result = paginate_vec(&items, None, None).unwrap();
|
||||||
|
assert_eq!(result.items.len(), 10);
|
||||||
|
assert_eq!(result.total_items, 10);
|
||||||
|
assert_eq!(result.page, None);
|
||||||
|
assert_eq!(result.total_pages, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginate_vec_first_page() {
|
||||||
|
let items: Vec<i32> = (1..=25).collect();
|
||||||
|
let result = paginate_vec(&items, Some(1), Some(10)).unwrap();
|
||||||
|
assert_eq!(result.items, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||||
|
assert_eq!(result.total_items, 25);
|
||||||
|
assert_eq!(result.total_pages, Some(3));
|
||||||
|
assert_eq!(result.page, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginate_vec_last_partial_page() {
|
||||||
|
let items: Vec<i32> = (1..=25).collect();
|
||||||
|
let result = paginate_vec(&items, Some(3), Some(10)).unwrap();
|
||||||
|
assert_eq!(result.items, vec![21, 22, 23, 24, 25]);
|
||||||
|
assert_eq!(result.total_items, 25);
|
||||||
|
assert_eq!(result.total_pages, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginate_vec_page_beyond_range_returns_empty() {
|
||||||
|
let items: Vec<i32> = (1..=10).collect();
|
||||||
|
let result = paginate_vec(&items, Some(5), Some(10)).unwrap();
|
||||||
|
assert_eq!(result.items.len(), 0);
|
||||||
|
assert_eq!(result.total_items, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginate_vec_empty_list() {
|
||||||
|
let items: Vec<i32> = vec![];
|
||||||
|
let result = paginate_vec(&items, Some(1), Some(10)).unwrap();
|
||||||
|
assert_eq!(result.items.len(), 0);
|
||||||
|
assert_eq!(result.total_items, 0);
|
||||||
|
assert_eq!(result.total_pages, Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginate_vec_zero_page_returns_error() {
|
||||||
|
let items: Vec<i32> = (1..=10).collect();
|
||||||
|
assert!(paginate_vec(&items, Some(0), Some(10)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginate_vec_zero_page_size_returns_error() {
|
||||||
|
let items: Vec<i32> = (1..=10).collect();
|
||||||
|
assert!(paginate_vec(&items, Some(1), Some(0)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginate_vec_single_item() {
|
||||||
|
let items = vec![42];
|
||||||
|
let result = paginate_vec(&items, Some(1), Some(10)).unwrap();
|
||||||
|
assert_eq!(result.items, vec![42]);
|
||||||
|
assert_eq!(result.total_items, 1);
|
||||||
|
assert_eq!(result.total_pages, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginate_vec_exact_page_boundary() {
|
||||||
|
let items: Vec<i32> = (1..=20).collect();
|
||||||
|
let result = paginate_vec(&items, Some(2), Some(10)).unwrap();
|
||||||
|
assert_eq!(result.items, vec![11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
|
||||||
|
assert_eq!(result.total_pages, Some(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,10 +16,9 @@
|
|||||||
// 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::{common::signal::SIGNAL_MANAGER, error::BichonResult};
|
||||||
use crate::modules::{common::signal::SIGNAL_MANAGER, error::BichonResult};
|
|
||||||
use std::{future::Future, time::Duration};
|
use std::{future::Future, time::Duration};
|
||||||
use tokio::{sync::oneshot, time::MissedTickBehavior};
|
use tokio::{sync::oneshot, task::JoinHandle, time::MissedTickBehavior};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
pub struct PeriodicTask {
|
pub struct PeriodicTask {
|
||||||
@@ -28,7 +27,7 @@ pub struct PeriodicTask {
|
|||||||
|
|
||||||
pub struct TaskHandle {
|
pub struct TaskHandle {
|
||||||
cancel_sender: Option<oneshot::Sender<()>>,
|
cancel_sender: Option<oneshot::Sender<()>>,
|
||||||
join_handle: tokio::task::JoinHandle<()>,
|
join_handle: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TaskHandle {
|
impl TaskHandle {
|
||||||
@@ -38,6 +37,10 @@ impl TaskHandle {
|
|||||||
}
|
}
|
||||||
let _ = self.join_handle.await;
|
let _ = self.join_handle.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn stop(self) {
|
||||||
|
let _ = self.join_handle.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PeriodicTask {
|
impl PeriodicTask {
|
||||||
@@ -82,6 +85,14 @@ impl PeriodicTask {
|
|||||||
let mut cancel_receiver = cancel_receiver_opt;
|
let mut cancel_receiver = cancel_receiver_opt;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
let cancel_fut = async {
|
||||||
|
if let Some(ref mut rx) = cancel_receiver {
|
||||||
|
rx.await.ok();
|
||||||
|
} else {
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => {
|
||||||
match task(param).await {
|
match task(param).await {
|
||||||
@@ -92,13 +103,7 @@ impl PeriodicTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// only enabled if cancel_receiver is Some
|
// only enabled if cancel_receiver is Some
|
||||||
_ = async {
|
_ = cancel_fut => {
|
||||||
if let Some(ref mut rx) = cancel_receiver {
|
|
||||||
rx.await.ok()
|
|
||||||
} else {
|
|
||||||
futures::future::pending().await
|
|
||||||
}
|
|
||||||
} => {
|
|
||||||
info!("Task '{}' received cancellation signal", name_clone);
|
info!("Task '{}' received cancellation signal", name_clone);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,18 +16,17 @@
|
|||||||
// 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::{
|
||||||
modules::{
|
{
|
||||||
context::Initialize,
|
context::Initialize,
|
||||||
error::{code::ErrorCode, BichonResult},
|
error::{code::ErrorCode, BichonResult},
|
||||||
},
|
},
|
||||||
raise_error,
|
raise_error,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct RustMailerTls;
|
pub struct BichonTls;
|
||||||
|
|
||||||
impl Initialize for RustMailerTls {
|
impl Initialize for BichonTls {
|
||||||
async fn initialize() -> BichonResult<()> {
|
async fn initialize() -> BichonResult<()> {
|
||||||
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
|
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -38,4 +37,3 @@ impl Initialize for RustMailerTls {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,12 +16,9 @@
|
|||||||
// 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 std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::modules::{
|
use crate::{context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal};
|
||||||
context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal,
|
|
||||||
};
|
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
pub static SIGNAL_MANAGER: LazyLock<SignalManager> = LazyLock::new(SignalManager::new);
|
pub static SIGNAL_MANAGER: LazyLock<SignalManager> = LazyLock::new(SignalManager::new);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -23,6 +23,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use email_address::EmailAddress;
|
use email_address::EmailAddress;
|
||||||
|
|
||||||
use poem_openapi::Validator;
|
use poem_openapi::Validator;
|
||||||
|
|
||||||
pub struct EmailValidator;
|
pub struct EmailValidator;
|
||||||
@@ -33,6 +34,7 @@ impl Display for EmailValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl Validator<String> for EmailValidator {
|
impl Validator<String> for EmailValidator {
|
||||||
fn check(&self, value: &String) -> bool {
|
fn check(&self, value: &String) -> bool {
|
||||||
match EmailAddress::from_str(value) {
|
match EmailAddress::from_str(value) {
|
||||||
@@ -40,4 +42,4 @@ impl Validator<String> for EmailValidator {
|
|||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,30 +16,29 @@
|
|||||||
// 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::{cache::imap::task::SYNC_TASKS, error::BichonResult};
|
||||||
use crate::modules::{cache::imap::task::SYNC_TASKS, error::BichonResult};
|
|
||||||
use std::{sync::LazyLock, time::Duration};
|
use std::{sync::LazyLock, time::Duration};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
pub static SYNC_CONTROLLER: LazyLock<SyncController> = LazyLock::new(SyncController::new);
|
pub static DOWNLOAD_CONTROLLER: LazyLock<DownloadController> =
|
||||||
|
LazyLock::new(DownloadController::new);
|
||||||
|
|
||||||
pub struct SyncController {
|
pub struct DownloadController {
|
||||||
channel: mpsc::Sender<(u64, String)>, // Channel to trigger account sync by account ID
|
channel: mpsc::Sender<(u64, String)>, // Channel to trigger account download by account ID
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncController {
|
impl DownloadController {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (tx, mut rx) = mpsc::channel::<(u64, String)>(100);
|
let (tx, mut rx) = mpsc::channel::<(u64, String)>(100);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some((account_id, email)) = rx.recv().await {
|
while let Some((account_id, email)) = rx.recv().await {
|
||||||
match Self::start_syncer(account_id, email.clone()).await {
|
match Self::start_download(account_id, email.clone()).await {
|
||||||
Ok(Some(_)) => {}
|
Ok(_) => {}
|
||||||
Ok(None) => {}
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!(
|
error!(
|
||||||
"Failed to prepare and start syncer of account {{{}-{}}}, error: {:#?}",
|
"Failed to prepare and start scheduled download of account {{{}-{}}}, error: {:#?}",
|
||||||
&account_id, &email, err
|
&account_id, &email, err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,26 +46,26 @@ impl SyncController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
SyncController { channel: tx }
|
DownloadController { channel: tx }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger synchronization for a specific account
|
/// Trigger synchronization for a specific account
|
||||||
pub async fn trigger_start(&self, account_id: u64, email: String) {
|
pub async fn trigger_schedule(&self, account_id: u64, email: String) {
|
||||||
if let Err(e) = self.channel.send((account_id, email)).await {
|
if let Err(e) = self.channel.send((account_id, email)).await {
|
||||||
error!(
|
error!(
|
||||||
"Failed to trigger synchronization for account={{{}}}, error: {:?}",
|
"Failed to trigger download for account={{{}}}, error: {:?}",
|
||||||
account_id, e
|
account_id, e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_syncer(account_id: u64, email: String) -> BichonResult<Option<()>> {
|
async fn start_download(account_id: u64, email: String) -> BichonResult<()> {
|
||||||
info!(
|
info!(
|
||||||
"Account syncer starting for account: {}-{}.",
|
"Account download starting for account: {}-{}.",
|
||||||
account_id, email
|
account_id, email
|
||||||
);
|
);
|
||||||
SYNC_TASKS.start_account_sync_task(account_id, email).await;
|
SYNC_TASKS.start_download_task(account_id, email).await;
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
Ok(Some(()))
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
75
crates/core/src/context/executors.rs
Normal file
75
crates/core/src/context/executors.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::account::migration::AccountType;
|
||||||
|
use crate::context::Initialize;
|
||||||
|
use crate::{
|
||||||
|
{
|
||||||
|
account::migration::AccountModel, context::controller::DOWNLOAD_CONTROLLER, error::BichonResult,
|
||||||
|
},
|
||||||
|
utc_now,
|
||||||
|
};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub static BICHON_CONTEXT: LazyLock<BichonContext> = LazyLock::new(BichonContext::new);
|
||||||
|
|
||||||
|
pub struct BichonContext {
|
||||||
|
start_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Initialize for BichonContext {
|
||||||
|
async fn initialize() -> BichonResult<()> {
|
||||||
|
BICHON_CONTEXT.start_account_downloader().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BichonContext {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
start_at: utc_now!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn uptime_ms(&self) -> i64 {
|
||||||
|
utc_now!() - self.start_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_account_downloader(&self) -> BichonResult<()> {
|
||||||
|
let accounts = AccountModel::list_all()?;
|
||||||
|
let active_accounts: Vec<AccountModel> = accounts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| a.enabled && matches!(a.account_type, AccountType::IMAP))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if active_accounts.is_empty() {
|
||||||
|
info!("No active accounts found for account initialization.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"System has {} active IMAP accounts to initialize.",
|
||||||
|
active_accounts.len()
|
||||||
|
);
|
||||||
|
for account in active_accounts {
|
||||||
|
DOWNLOAD_CONTROLLER
|
||||||
|
.trigger_schedule(account.id, account.email)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,17 +16,16 @@
|
|||||||
// 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::{common::periodic::TaskHandle, error::BichonResult};
|
||||||
|
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod executors;
|
pub mod executors;
|
||||||
pub mod status;
|
|
||||||
|
|
||||||
#[allow(async_fn_in_trait)]
|
#[allow(async_fn_in_trait)]
|
||||||
pub trait Initialize {
|
pub trait Initialize {
|
||||||
async fn initialize() -> BichonResult<()>;
|
async fn initialize() -> BichonResult<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait RustMailTask {
|
pub trait BichonTask {
|
||||||
fn start();
|
fn start() -> TaskHandle;
|
||||||
}
|
}
|
||||||
232
crates/core/src/dashboard/mod.rs
Normal file
232
crates/core/src/dashboard/mod.rs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
store::tantivy::{
|
||||||
|
attachment::ATTACHMENT_MANAGER,
|
||||||
|
envelope::ENVELOPE_MANAGER,
|
||||||
|
fields::{F_CONTENT_HASH, F_ID},
|
||||||
|
schema::SchemaTools,
|
||||||
|
},
|
||||||
|
users::permissions::Permission,
|
||||||
|
};
|
||||||
|
//use poem_openapi::Object;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use tantivy::{schema::Value, TantivyDocument};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
bichon_version, raise_error,
|
||||||
|
{
|
||||||
|
account::migration::AccountModel,
|
||||||
|
common::auth::ClientContext,
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
settings::dir::DATA_DIR_MANAGER,
|
||||||
|
utils::get_total_size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct DashboardStats {
|
||||||
|
pub account_count: usize, // Number of accounts
|
||||||
|
pub email_count: u64, // Total number of emails
|
||||||
|
pub attachment_count: u64, // Total number of attachments
|
||||||
|
pub total_size_bytes: u64, // Total size of all emails (in bytes)
|
||||||
|
pub storage_usage_bytes: u64, // Actual storage used (in bytes)
|
||||||
|
pub index_usage_bytes: u64, // Index storage size (in bytes)
|
||||||
|
pub recent_activity: Vec<TimeBucket>, // Email activity over recent days
|
||||||
|
pub top_senders: Vec<Group>, // Top 10 senders
|
||||||
|
pub top_accounts: Vec<Group>, // Top 10 accounts
|
||||||
|
pub with_attachment_count: u64, // Emails with attachments
|
||||||
|
pub without_attachment_count: u64, // Emails without attachments
|
||||||
|
pub top_largest_emails: Vec<LargestEmail>, // Top 10 largest emails
|
||||||
|
pub top_largest_attachments: Vec<LargestAttachment>, // Top 10 largest attachments
|
||||||
|
pub system_version: String, // The semantic version string of the currently running backend service
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DashboardStats {
|
||||||
|
pub async fn get(context: ClientContext) -> BichonResult<Self> {
|
||||||
|
let has_all_accounts = context.has_permission(None, Permission::ACCOUNT_MANAGE_ALL);
|
||||||
|
let authorized_ids: Option<HashSet<u64>> = if has_all_accounts {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(context.user.account_access_map.keys().cloned().collect())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stat = ENVELOPE_MANAGER.get_dashboard_stats(&authorized_ids)?;
|
||||||
|
|
||||||
|
stat.top_largest_emails = ENVELOPE_MANAGER.top_10_largest_emails(&authorized_ids)?;
|
||||||
|
stat.top_largest_attachments =
|
||||||
|
ATTACHMENT_MANAGER.top_10_largest_attachments(&authorized_ids)?;
|
||||||
|
|
||||||
|
stat.account_count = if has_all_accounts {
|
||||||
|
AccountModel::count()?
|
||||||
|
} else {
|
||||||
|
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
stat.email_count = ENVELOPE_MANAGER.total_emails(&authorized_ids)?;
|
||||||
|
stat.attachment_count = ATTACHMENT_MANAGER.total_attachments(&authorized_ids)?;
|
||||||
|
if has_all_accounts {
|
||||||
|
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.storage_dir)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
stat.index_usage_bytes = get_total_size(&&DATA_DIR_MANAGER.envelope_dir)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
} else {
|
||||||
|
stat.storage_usage_bytes = 0;
|
||||||
|
stat.index_usage_bytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
stat.system_version = bichon_version!().to_string();
|
||||||
|
|
||||||
|
Ok(stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct TimeBucket {
|
||||||
|
pub timestamp_ms: i64, // Timestamp in milliseconds
|
||||||
|
pub count: u64, // Number of emails in this time bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct Group {
|
||||||
|
pub key: String,
|
||||||
|
pub count: u64, // Number of emails from this sender
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct LargestEmail {
|
||||||
|
pub subject: String, // Email subject
|
||||||
|
pub size_bytes: u64, // Email size in bytes
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LargestEmail {
|
||||||
|
pub fn from_tantivy_doc(document: &TantivyDocument) -> BichonResult<Self> {
|
||||||
|
let fields = SchemaTools::email_fields();
|
||||||
|
let value = document.get_first(fields.f_size).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"miss 'size' field in tantivy document".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let size_bytes = value.as_u64().ok_or_else(|| {
|
||||||
|
raise_error!("'size' field is not a u64".into(), ErrorCode::InternalError)
|
||||||
|
})?;
|
||||||
|
let value = document.get_first(fields.f_subject).ok_or_else(|| {
|
||||||
|
raise_error!("'subject' field not found".into(), ErrorCode::InternalError)
|
||||||
|
})?;
|
||||||
|
let subject = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"'subject' field is not a string".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let value = document.get_first(fields.f_id).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("'{}' field not found", F_ID),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let id = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("'{}' field is not a string", F_ID),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let envelope = LargestEmail {
|
||||||
|
subject,
|
||||||
|
size_bytes,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(envelope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct LargestAttachment {
|
||||||
|
pub name: String, // Attachment name
|
||||||
|
pub size_bytes: u64, // Attachment size in bytes
|
||||||
|
pub id: String,
|
||||||
|
pub content_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LargestAttachment {
|
||||||
|
pub fn from_tantivy_doc(document: &TantivyDocument) -> BichonResult<Self> {
|
||||||
|
let fields = SchemaTools::attachment_fields();
|
||||||
|
let value = document.get_first(fields.f_size).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"miss 'size' field in tantivy document".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let size_bytes = value.as_u64().ok_or_else(|| {
|
||||||
|
raise_error!("'size' field is not a u64".into(), ErrorCode::InternalError)
|
||||||
|
})?;
|
||||||
|
let name = document
|
||||||
|
.get_first(fields.f_name_exact)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
|
let value = document.get_first(fields.f_id).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("'{}' field not found", F_ID),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let id = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("'{}' field is not a string", F_ID),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let value = document.get_first(fields.f_content_hash).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("'{}' field not found", F_CONTENT_HASH),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let content_hash = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("'{}' field is not a string", F_CONTENT_HASH),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let attachment = LargestAttachment {
|
||||||
|
name,
|
||||||
|
size_bytes,
|
||||||
|
id,
|
||||||
|
content_hash,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
crates/core/src/database/manager.rs
Normal file
48
crates/core/src/database/manager.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::settings::dir::DATA_DIR_MANAGER;
|
||||||
|
use memdb::{Durability, MemDb};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub static DB_MANAGER: LazyLock<DatabaseManager> = LazyLock::new(DatabaseManager::new);
|
||||||
|
|
||||||
|
pub struct DatabaseManager {
|
||||||
|
db: MemDb,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseManager {
|
||||||
|
fn new() -> Self {
|
||||||
|
let db_path = &DATA_DIR_MANAGER.memdb_dir;
|
||||||
|
std::fs::create_dir_all(db_path).expect("Failed to create memdb data directory");
|
||||||
|
|
||||||
|
let db = MemDb::open_with(db_path, Durability::Full)
|
||||||
|
.expect("Failed to open memdb database");
|
||||||
|
|
||||||
|
// Start periodic snapshot worker (every 5 minutes)
|
||||||
|
db.start_snapshot_worker(Duration::from_secs(300));
|
||||||
|
|
||||||
|
DatabaseManager { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the MemDb instance.
|
||||||
|
pub fn db(&self) -> &MemDb {
|
||||||
|
&self.db
|
||||||
|
}
|
||||||
|
}
|
||||||
225
crates/core/src/database/mod.rs
Normal file
225
crates/core/src/database/mod.rs
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::common::paginated::Paginated;
|
||||||
|
use crate::error::code::ErrorCode;
|
||||||
|
use crate::error::BichonResult;
|
||||||
|
use crate::raise_error;
|
||||||
|
use memdb::{MemDb, Transaction};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
pub mod manager;
|
||||||
|
|
||||||
|
/// Trait for models that can be stored in MemDb collections.
|
||||||
|
pub trait MemDbModel: Serialize + DeserializeOwned + Clone + Send + 'static {
|
||||||
|
/// The collection name this model is stored under.
|
||||||
|
fn collection() -> &'static str;
|
||||||
|
/// The primary key as a string for MemDb storage.
|
||||||
|
fn key(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Insert ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn insert_impl<M: MemDbModel>(db: &MemDb, item: M) -> BichonResult<()> {
|
||||||
|
let coll = db.collection(M::collection());
|
||||||
|
let key = item.key();
|
||||||
|
coll.insert(key, &item)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn batch_insert_impl<M: MemDbModel>(db: &MemDb, items: Vec<M>) -> BichonResult<()> {
|
||||||
|
let txn = db.transaction();
|
||||||
|
let mut txn = txn;
|
||||||
|
for item in &items {
|
||||||
|
txn = txn
|
||||||
|
.insert(M::collection(), item.key(), item)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
}
|
||||||
|
txn.commit()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Upsert ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn upsert_impl<M: MemDbModel>(db: &MemDb, item: M) -> BichonResult<()> {
|
||||||
|
let coll = db.collection(M::collection());
|
||||||
|
coll.upsert(item.key(), &item)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn batch_upsert_impl<M: MemDbModel>(db: &MemDb, items: Vec<M>) -> BichonResult<()> {
|
||||||
|
let txn = db.transaction();
|
||||||
|
let mut txn = txn;
|
||||||
|
for item in &items {
|
||||||
|
txn = txn
|
||||||
|
.upsert(M::collection(), item.key(), item)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
}
|
||||||
|
txn.commit()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Find ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn find_impl<M: MemDbModel>(db: &MemDb, key: &str) -> BichonResult<Option<M>> {
|
||||||
|
let coll = db.collection(M::collection());
|
||||||
|
coll.get(key)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filter (replaces secondary key queries) ──────────────────────────────
|
||||||
|
|
||||||
|
pub fn filter_impl<M, F>(db: &MemDb, predicate: F) -> BichonResult<Vec<M>>
|
||||||
|
where
|
||||||
|
M: MemDbModel,
|
||||||
|
F: Fn(&M) -> bool + Send + 'static,
|
||||||
|
{
|
||||||
|
let coll = db.collection(M::collection());
|
||||||
|
coll.filter(predicate)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Update (read-modify-write under a single spawn_blocking) ─────────────
|
||||||
|
|
||||||
|
pub fn update_impl<M: MemDbModel>(
|
||||||
|
db: &MemDb,
|
||||||
|
key: &str,
|
||||||
|
update_fn: impl FnOnce(M) -> BichonResult<M> + Send + 'static,
|
||||||
|
) -> BichonResult<M> {
|
||||||
|
let coll = db.collection(M::collection());
|
||||||
|
let current: M = coll
|
||||||
|
.get_required(key)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
let updated = update_fn(current)?;
|
||||||
|
coll.upsert(key, &updated)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn delete_impl<M: MemDbModel>(db: &MemDb, key: &str) -> BichonResult<()> {
|
||||||
|
let coll = db.collection(M::collection());
|
||||||
|
let existed = coll
|
||||||
|
.delete(key)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
if !existed {
|
||||||
|
return Err(raise_error!(
|
||||||
|
format!("{} '{}' not found for deletion", M::collection(), key),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn batch_delete_impl<M: MemDbModel>(db: &MemDb, keys: Vec<String>) -> BichonResult<usize> {
|
||||||
|
let txn = db.transaction();
|
||||||
|
let mut txn = txn;
|
||||||
|
let mut count = 0usize;
|
||||||
|
for key in &keys {
|
||||||
|
txn = txn.delete(M::collection(), key.clone());
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
txn.commit()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List / Count ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn list_all_impl<M: MemDbModel>(db: &MemDb) -> BichonResult<Vec<M>> {
|
||||||
|
let coll = db.collection(M::collection());
|
||||||
|
coll.list_all()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count_impl<M: MemDbModel>(db: &MemDb) -> BichonResult<usize> {
|
||||||
|
let coll = db.collection(M::collection());
|
||||||
|
Ok(coll.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Paginate ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn paginate_impl<M: MemDbModel>(
|
||||||
|
db: &MemDb,
|
||||||
|
page: Option<u64>,
|
||||||
|
page_size: Option<u64>,
|
||||||
|
desc: Option<bool>,
|
||||||
|
) -> BichonResult<Paginated<M>> {
|
||||||
|
let coll = db.collection(M::collection());
|
||||||
|
let total_items = coll.count() as u64;
|
||||||
|
|
||||||
|
let (offset, total_pages) = match (page, page_size) {
|
||||||
|
(Some(p), Some(s)) if p > 0 && s > 0 => {
|
||||||
|
let offset = (p - 1) * s;
|
||||||
|
let total_pages = if total_items > 0 {
|
||||||
|
(total_items as f64 / s as f64).ceil() as u64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
(Some(offset), Some(total_pages))
|
||||||
|
}
|
||||||
|
(Some(0), _) | (_, Some(0)) => {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"'page' and 'page_size' must be greater than 0.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let all: Vec<M> = coll
|
||||||
|
.list_all()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
let items: Vec<M> = match desc {
|
||||||
|
Some(true) => {
|
||||||
|
let iter: Vec<M> = all.into_iter().rev().collect();
|
||||||
|
let skip = offset.unwrap_or(0) as usize;
|
||||||
|
let take = page_size.unwrap_or(total_items) as usize;
|
||||||
|
iter.into_iter().skip(skip).take(take).collect()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let skip = offset.unwrap_or(0) as usize;
|
||||||
|
let take = page_size.unwrap_or(total_items) as usize;
|
||||||
|
all.into_iter().skip(skip).take(take).collect()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Paginated::new(
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_items,
|
||||||
|
total_pages,
|
||||||
|
items,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transaction ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Execute operations within a single atomic transaction (one WAL entry).
|
||||||
|
pub fn with_transaction(
|
||||||
|
db: &MemDb,
|
||||||
|
f: impl FnOnce(Transaction) -> BichonResult<Transaction> + Send + 'static,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
let txn = db.transaction();
|
||||||
|
let txn = f(txn)?;
|
||||||
|
txn.commit()
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
|
}
|
||||||
565
crates/core/src/envelope/extractor.rs
Normal file
565
crates/core/src/envelope/extractor.rs
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::common::AddrVec;
|
||||||
|
use crate::envelope::meta::parse_bichon_metadata;
|
||||||
|
use crate::envelope::utils::normalize_subject;
|
||||||
|
use crate::error::code::ErrorCode;
|
||||||
|
use crate::error::BichonResult;
|
||||||
|
use crate::message::content::AttachmentInfo;
|
||||||
|
use crate::store::blob::{DetachedEmail, BLOB_MANAGER};
|
||||||
|
use crate::store::tantivy::attachment::ATTACHMENT_MANAGER;
|
||||||
|
use crate::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||||
|
use crate::store::tantivy::model::{AttachmentModel, EnvelopeWithAttachments};
|
||||||
|
use crate::utils::html::extract_text;
|
||||||
|
use crate::utils::{compute_content_hash, hex_hash};
|
||||||
|
use crate::{id, store::envelope::Envelope};
|
||||||
|
use crate::{raise_error, utc_now};
|
||||||
|
use async_imap::types::Fetch;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use mail_parser::{Address, HeaderName, Message, MessageParser, MimeHeaders};
|
||||||
|
use tantivy::TantivyDocument;
|
||||||
|
use tantivy::schema::Facet;
|
||||||
|
use tracing::error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn extract_envelope_and_store_it(
|
||||||
|
fetch: Fetch,
|
||||||
|
account_id: u64,
|
||||||
|
mailbox_id: u64,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
let internal_date = fetch
|
||||||
|
.internal_date()
|
||||||
|
.map(|d| d.timestamp_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let uid = fetch.uid.unwrap_or(0);
|
||||||
|
let body = fetch
|
||||||
|
.body()
|
||||||
|
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
|
||||||
|
let size = fetch.size.unwrap_or(body.len() as u32);
|
||||||
|
extract_envelope_core(body, uid, size, internal_date, account_id, mailbox_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn extract_envelope_from_eml(
|
||||||
|
body: &[u8],
|
||||||
|
account_id: u64,
|
||||||
|
mailbox_id: u64,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
extract_envelope_core(body, 0, body.len() as u32, 0, account_id, mailbox_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn extract_envelope_from_smtp(
|
||||||
|
body: &[u8],
|
||||||
|
account_id: u64,
|
||||||
|
mailbox_id: u64,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
extract_envelope_core(
|
||||||
|
body,
|
||||||
|
0,
|
||||||
|
body.len() as u32,
|
||||||
|
utc_now!(),
|
||||||
|
account_id,
|
||||||
|
mailbox_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn extract_envelope_core(
|
||||||
|
body: &[u8],
|
||||||
|
uid: u32,
|
||||||
|
size: u32,
|
||||||
|
internal_date: i64,
|
||||||
|
account_id: u64,
|
||||||
|
mailbox_id: u64,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
let email_content_hash = compute_content_hash(body);
|
||||||
|
let message: Message<'_> = MessageParser::new().parse(body).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Email header parse result is not available".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let preview_limit = 100;
|
||||||
|
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
|
||||||
|
text
|
||||||
|
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
|
||||||
|
extract_text(html)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
|
||||||
|
let preview = if text.chars().count() > preview_limit {
|
||||||
|
text.chars().take(preview_limit).collect::<String>() + "..."
|
||||||
|
} else {
|
||||||
|
text.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let body_text = text;
|
||||||
|
|
||||||
|
let message_id = message
|
||||||
|
.message_id()
|
||||||
|
.map(String::from)
|
||||||
|
.unwrap_or_else(generate_message_id);
|
||||||
|
|
||||||
|
let in_reply_to = message.in_reply_to().as_text().map(String::from);
|
||||||
|
let references = extract_references(&message);
|
||||||
|
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
|
||||||
|
|
||||||
|
let mut subject = message.subject().map(String::from).unwrap_or_default();
|
||||||
|
if subject.contains('\u{FFFD}') {
|
||||||
|
subject = normalize_subject(message.header_raw(HeaderName::Subject));
|
||||||
|
}
|
||||||
|
|
||||||
|
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
|
||||||
|
let internal_date = if internal_date == 0 {
|
||||||
|
date
|
||||||
|
} else {
|
||||||
|
internal_date
|
||||||
|
};
|
||||||
|
let parse_addrs = |addrs: Option<&Address<'_>>| {
|
||||||
|
addrs
|
||||||
|
.map(|addr| {
|
||||||
|
AddrVec::from(addr)
|
||||||
|
.0
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|a| a.address)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let bcc = parse_addrs(message.bcc());
|
||||||
|
let cc = parse_addrs(message.cc());
|
||||||
|
let to = parse_addrs(message.to());
|
||||||
|
|
||||||
|
let from = message
|
||||||
|
.from()
|
||||||
|
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
|
||||||
|
.and_then(|add| add.address)
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let attachment_count = message.attachment_count();
|
||||||
|
let attachments = detach_and_store_attachments(body, &message, &email_content_hash).await;
|
||||||
|
|
||||||
|
let envelope_id = Uuid::new_v4().to_string();
|
||||||
|
let now = utc_now!();
|
||||||
|
|
||||||
|
|
||||||
|
let mut final_tags = Vec::new();
|
||||||
|
|
||||||
|
if let Some(meta_header) = message.header_raw("X-Bichon-Metadata") {
|
||||||
|
if let Some(bmd) = parse_bichon_metadata(meta_header) {
|
||||||
|
if let Some(tags) = bmd.tags {
|
||||||
|
let validated_tags: Result<Vec<String>, _> = tags
|
||||||
|
.iter()
|
||||||
|
.map(|tag| {
|
||||||
|
Facet::from_text(tag)
|
||||||
|
.map(|_| tag.clone())
|
||||||
|
.map_err(|e| e)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match validated_tags {
|
||||||
|
Ok(valid_list) => {
|
||||||
|
final_tags = valid_list;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Tag validation failed, ignoring all tags: {:#?}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachment_docs: Vec<TantivyDocument> = attachments
|
||||||
|
.iter()
|
||||||
|
.filter(|a| !a.inline || a.content_id.is_none())
|
||||||
|
.map(|a| AttachmentModel {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
envelope_id: envelope_id.clone(),
|
||||||
|
account_id,
|
||||||
|
account_email: None,
|
||||||
|
mailbox_id,
|
||||||
|
mailbox_name: None,
|
||||||
|
subject: subject.clone(),
|
||||||
|
content_hash: a.content_hash.clone(),
|
||||||
|
from: from.clone(),
|
||||||
|
date,
|
||||||
|
ingest_at: now,
|
||||||
|
size: a.size as u64,
|
||||||
|
ext: a.get_extension(),
|
||||||
|
category: a.get_category().to_string(),
|
||||||
|
content_type: a.file_type.clone(),
|
||||||
|
shard_id: 0,
|
||||||
|
text: None,
|
||||||
|
has_text: false,
|
||||||
|
is_ocr: false,
|
||||||
|
page_count: None,
|
||||||
|
is_indexed: false,
|
||||||
|
is_message: a.is_message,
|
||||||
|
name: a.filename.clone(),
|
||||||
|
tags: None,
|
||||||
|
auto_tags: None,
|
||||||
|
}).map(|a|a.into_document())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let envelope = Envelope {
|
||||||
|
id: envelope_id,
|
||||||
|
message_id,
|
||||||
|
account_id,
|
||||||
|
mailbox_id,
|
||||||
|
uid,
|
||||||
|
subject,
|
||||||
|
preview,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
|
date,
|
||||||
|
internal_date,
|
||||||
|
ingest_at: now,
|
||||||
|
size,
|
||||||
|
thread_id,
|
||||||
|
attachment_count,
|
||||||
|
regular_attachment_count: attachment_docs.len(),
|
||||||
|
tags: (!final_tags.is_empty()).then_some(final_tags),
|
||||||
|
account_email: None,
|
||||||
|
mailbox_name: None,
|
||||||
|
content_hash: email_content_hash,
|
||||||
|
};
|
||||||
|
// 'attachments' contains both regular and inline attachments
|
||||||
|
let ea = EnvelopeWithAttachments {
|
||||||
|
envelope,
|
||||||
|
attachments: Some(attachments),
|
||||||
|
};
|
||||||
|
let doc = ea.to_document(&body_text, 0)?;
|
||||||
|
ENVELOPE_MANAGER.queue(doc).await;
|
||||||
|
for doc in attachment_docs {
|
||||||
|
ATTACHMENT_MANAGER.queue(doc).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_envelope_from_nested_message(
|
||||||
|
message: Message<'_>,
|
||||||
|
account_id: u64,
|
||||||
|
) -> BichonResult<Envelope> {
|
||||||
|
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
|
||||||
|
text
|
||||||
|
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
|
||||||
|
extract_text(html)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let message_id = message
|
||||||
|
.message_id()
|
||||||
|
.map(String::from)
|
||||||
|
.unwrap_or_else(generate_message_id);
|
||||||
|
|
||||||
|
let in_reply_to = message.in_reply_to().as_text().map(String::from);
|
||||||
|
let references = extract_references(&message);
|
||||||
|
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
|
||||||
|
|
||||||
|
let mut subject = message.subject().map(String::from).unwrap_or_default();
|
||||||
|
if subject.contains('\u{FFFD}') {
|
||||||
|
subject = normalize_subject(message.header_raw(HeaderName::Subject));
|
||||||
|
}
|
||||||
|
|
||||||
|
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
|
||||||
|
|
||||||
|
let parse_addrs = |addrs: Option<&Address<'_>>| {
|
||||||
|
addrs
|
||||||
|
.map(|addr| {
|
||||||
|
AddrVec::from(addr)
|
||||||
|
.0
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|a| a.address)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let bcc = parse_addrs(message.bcc());
|
||||||
|
let cc = parse_addrs(message.cc());
|
||||||
|
let to = parse_addrs(message.to());
|
||||||
|
|
||||||
|
let from = message
|
||||||
|
.from()
|
||||||
|
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
|
||||||
|
.and_then(|add| add.address)
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let envelope = Envelope {
|
||||||
|
id: Default::default(),
|
||||||
|
message_id,
|
||||||
|
account_id,
|
||||||
|
mailbox_id: Default::default(),
|
||||||
|
uid: Default::default(),
|
||||||
|
subject,
|
||||||
|
preview: text,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
|
date,
|
||||||
|
internal_date: Default::default(),
|
||||||
|
ingest_at: Default::default(),
|
||||||
|
size: Default::default(),
|
||||||
|
thread_id,
|
||||||
|
attachment_count: Default::default(),
|
||||||
|
regular_attachment_count: Default::default(),
|
||||||
|
tags: Default::default(),
|
||||||
|
account_email: Default::default(),
|
||||||
|
mailbox_name: Default::default(),
|
||||||
|
content_hash: Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_thread_id(
|
||||||
|
in_reply_to: Option<String>,
|
||||||
|
references: Option<Vec<String>>,
|
||||||
|
message_id: &str,
|
||||||
|
) -> String {
|
||||||
|
if in_reply_to.is_some() && references.as_ref().map_or(false, |r| !r.is_empty()) {
|
||||||
|
return hex_hash(&references.as_ref().unwrap()[0]);
|
||||||
|
}
|
||||||
|
hex_hash(message_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_message_id() -> String {
|
||||||
|
let ts = utc_now!();
|
||||||
|
let pid = std::process::id();
|
||||||
|
format!("<{:016x}.{}.{}@{}>", id!(128), ts, pid, "bichon")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_references(message: &Message<'_>) -> Option<Vec<String>> {
|
||||||
|
match message.references() {
|
||||||
|
mail_parser::HeaderValue::Text(cow) => Some(vec![cow.to_string()]),
|
||||||
|
mail_parser::HeaderValue::TextList(vec) => {
|
||||||
|
Some(vec.iter().map(|cow| cow.to_string()).collect())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn detach_and_store_attachments(
|
||||||
|
original_body: &[u8],
|
||||||
|
message: &Message<'_>,
|
||||||
|
eml_content_hash: &str,
|
||||||
|
) -> Vec<AttachmentInfo> {
|
||||||
|
let mut stripped_eml = original_body.to_vec();
|
||||||
|
let mut attachment_infos = Vec::new();
|
||||||
|
// Step 1: Collect and sort attachment ranges in reverse to maintain offset integrity
|
||||||
|
let mut ranges: Vec<_> = message
|
||||||
|
.attachments()
|
||||||
|
.map(|att| {
|
||||||
|
(
|
||||||
|
att.raw_body_offset() as usize,
|
||||||
|
att.raw_end_offset() as usize,
|
||||||
|
att,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ranges.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
let mut attachments = Vec::with_capacity(ranges.len());
|
||||||
|
for (raw_start, raw_end, att) in ranges {
|
||||||
|
// Step 2: Extract raw bytes and store them as standalone documents
|
||||||
|
let raw_bytes = &original_body[raw_start..raw_end];
|
||||||
|
let content_hash = compute_content_hash(att.contents());
|
||||||
|
|
||||||
|
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));//
|
||||||
|
|
||||||
|
// Step 3: Replace raw attachment content with a hash-based placeholder
|
||||||
|
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
|
||||||
|
let p_bytes = placeholder.as_bytes();
|
||||||
|
stripped_eml.splice(raw_start..raw_end, p_bytes.iter().cloned());
|
||||||
|
|
||||||
|
let info = AttachmentInfo {
|
||||||
|
filename: att.attachment_name().map(|n| n.to_string()),
|
||||||
|
size: att.contents().len(),
|
||||||
|
inline: att
|
||||||
|
.content_disposition()
|
||||||
|
.map(|d| d.is_inline())
|
||||||
|
.unwrap_or(false),
|
||||||
|
file_type: att
|
||||||
|
.content_type()
|
||||||
|
.map(|ct| {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
ct.c_type.as_ref(),
|
||||||
|
ct.c_subtype.as_deref().unwrap_or("")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||||
|
content_id: att.content_id().map(|id| id.to_string()),
|
||||||
|
content_hash: content_hash.clone(),
|
||||||
|
is_message: att.is_message(),
|
||||||
|
};
|
||||||
|
|
||||||
|
attachment_infos.push(info);
|
||||||
|
}
|
||||||
|
// Step 4: Store the final stripped EML content
|
||||||
|
BLOB_MANAGER
|
||||||
|
.queue(DetachedEmail {
|
||||||
|
email: (eml_content_hash.to_string(), Bytes::from(stripped_eml)),
|
||||||
|
attachments: Some(attachments),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
attachment_infos
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reattach_eml_content(
|
||||||
|
account_id: u64,
|
||||||
|
envelope_id: String,
|
||||||
|
) -> BichonResult<(Envelope, Bytes)> {
|
||||||
|
let e = ENVELOPE_MANAGER
|
||||||
|
.get_envelope_by_id(account_id, &envelope_id)
|
||||||
|
?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!(
|
||||||
|
"Envelope not found: account_id={} envelope_id={}",
|
||||||
|
account_id, &envelope_id
|
||||||
|
),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let restored_eml = BLOB_MANAGER
|
||||||
|
.get_email(&e.envelope.content_hash)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!(
|
||||||
|
"Original email content not found: account_id={} envelope_id={} content_hash={}",
|
||||||
|
account_id, &envelope_id, &e.envelope.content_hash
|
||||||
|
),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !e.envelope.has_any_attachments() {
|
||||||
|
return Ok((e.envelope, restored_eml));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut restored_eml = restored_eml.to_vec();
|
||||||
|
let actual_count = e.attachments.as_ref().map(|a| a.len()).unwrap_or(0);
|
||||||
|
if e.envelope.attachment_count != actual_count {
|
||||||
|
return Err(raise_error!(
|
||||||
|
format!(
|
||||||
|
"Consistency check failed: envelope.attachment_count ({}) does not match attachments.len ({})",
|
||||||
|
e.envelope.attachment_count,
|
||||||
|
actual_count
|
||||||
|
),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for detail in e.attachments.unwrap() {
|
||||||
|
let placeholder_str = format!("<<BICHON_DETACH_HASH:{}>>", &detail.content_hash);
|
||||||
|
let pattern = placeholder_str.as_bytes();
|
||||||
|
let pattern_len = pattern.len();
|
||||||
|
|
||||||
|
let mut search_cursor = 0;
|
||||||
|
while let Some(pos) = restored_eml[search_cursor..]
|
||||||
|
.windows(pattern_len)
|
||||||
|
.position(|window| window == pattern)
|
||||||
|
{
|
||||||
|
let absolute_start = search_cursor + pos;
|
||||||
|
let absolute_end = absolute_start + pattern_len;
|
||||||
|
|
||||||
|
tasks.push((
|
||||||
|
absolute_start,
|
||||||
|
absolute_end,
|
||||||
|
detail.content_hash.clone(),
|
||||||
|
));
|
||||||
|
search_cursor = absolute_end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
|
||||||
|
for (start, end, hash) in tasks {
|
||||||
|
if let Some(original_data) = BLOB_MANAGER.get_attachment(&hash)? {
|
||||||
|
restored_eml.splice(start..end, original_data.iter().cloned());
|
||||||
|
} else {
|
||||||
|
error!("[ERROR] Missing attachment blob for hash: {}", hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((e.envelope, Bytes::from(restored_eml)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use html2text::config;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_various_html_with_overflow_enabled() {
|
||||||
|
let cases = [
|
||||||
|
("<p>Hello World</p>", "Simple paragraph"),
|
||||||
|
("<h1>Title</h1><p>Content</p>", "Heading + paragraph"),
|
||||||
|
("<ul><li>Item1</li><li>Item2</li></ul>", "Unordered list"),
|
||||||
|
(
|
||||||
|
"<strong>Bold</strong> and <em>italic</em>",
|
||||||
|
"Inline formatting",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<div><span>Nested</span> elements</div>",
|
||||||
|
"Nested inline elements inside block",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<table><tr><td>A</td><td>B</td></tr></table>",
|
||||||
|
"Simple table",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"<pre> preformatted text\n line2</pre>",
|
||||||
|
"Preformatted block",
|
||||||
|
),
|
||||||
|
("😃 emoji test", "Wide emoji"),
|
||||||
|
("<a href=\"#\">link</a>", "Anchor tag"),
|
||||||
|
(
|
||||||
|
"<blockquote><p>Quoted text</p></blockquote>",
|
||||||
|
"Blockquote with paragraph",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (html, desc) in cases {
|
||||||
|
let result = config::plain()
|
||||||
|
.allow_width_overflow()
|
||||||
|
.string_from_read(html.as_bytes(), 100);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(output) => {
|
||||||
|
println!("✓ Rendered ({}) =>\n{}", desc, output);
|
||||||
|
}
|
||||||
|
Err(e) => panic!("Unexpected error for {}: {:?}", desc, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/core/src/envelope/meta.rs
Normal file
15
crates/core/src/envelope/meta.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::base64_decode;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct BichonMetadata {
|
||||||
|
pub account_email: Option<String>,
|
||||||
|
pub mailbox_name: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_bichon_metadata(header_value: &str) -> Option<BichonMetadata> {
|
||||||
|
let decoded = base64_decode!(header_value.trim());
|
||||||
|
serde_json::from_slice(&decoded).ok()
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -17,4 +17,5 @@
|
|||||||
// 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 extractor;
|
pub mod extractor;
|
||||||
|
pub mod meta;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
@@ -1,3 +1,21 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
use mail_parser::parsers::MessageStream;
|
use mail_parser::parsers::MessageStream;
|
||||||
use regex::{Captures, Regex};
|
use regex::{Captures, Regex};
|
||||||
|
|
||||||
@@ -72,43 +90,86 @@ pub fn normalize_subject(raw_subject: Option<&str>) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::modules::envelope::utils::merge_contiguous_encoded_words;
|
use crate::envelope::utils::{merge_contiguous_encoded_words, normalize_subject};
|
||||||
|
|
||||||
|
// ── merge_contiguous_encoded_words ──────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn test3() {
|
fn merge_basic_utf8_b() {
|
||||||
let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!";
|
let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!";
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
merge_contiguous_encoded_words(s),
|
merge_contiguous_encoded_words(s),
|
||||||
"Hello =?UTF-8?B?SGVsbG8=V29ybGQ=?= !!!"
|
"Hello =?UTF-8?B?SGVsbG8=V29ybGQ=?= !!!"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_three_blocks() {
|
||||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= =?UTF-8?B?Qw==?=";
|
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= =?UTF-8?B?Qw==?=";
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
merge_contiguous_encoded_words(s),
|
merge_contiguous_encoded_words(s),
|
||||||
"=?UTF-8?B?QQ==Qg==Qw==?="
|
"=?UTF-8?B?QQ==Qg==Qw==?="
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_noncontiguous_blocks() {
|
||||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= test =?UTF-8?B?Qw==?= =?UTF-8?B?RA==?=";
|
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= test =?UTF-8?B?Qw==?= =?UTF-8?B?RA==?=";
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
merge_contiguous_encoded_words(s),
|
merge_contiguous_encoded_words(s),
|
||||||
"=?UTF-8?B?QQ==Qg==?= test =?UTF-8?B?Qw==RA==?="
|
"=?UTF-8?B?QQ==Qg==?= test =?UTF-8?B?Qw==RA==?="
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_different_charsets() {
|
||||||
let s = "=?UTF-8?B?QQ==?= =?GBK?B?Qg==?=";
|
let s = "=?UTF-8?B?QQ==?= =?GBK?B?Qg==?=";
|
||||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_different_encodings() {
|
||||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?=";
|
let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?=";
|
||||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_case_insensitive_encoding() {
|
||||||
let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?Qg==?=";
|
let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?Qg==?=";
|
||||||
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
|
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_encoded_word_unchanged() {
|
||||||
let s = "Hello =?UTF-8?B?SGVsbG8=?= !!!";
|
let s = "Hello =?UTF-8?B?SGVsbG8=?= !!!";
|
||||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_spaces_between_words() {
|
||||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?=";
|
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?=";
|
||||||
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
|
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_subject_line() {
|
||||||
let s = "Just a normal subject line";
|
let s = "Just a normal subject line";
|
||||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_quoted_printable() {
|
||||||
let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?World?=";
|
let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?World?=";
|
||||||
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?Q?Hello_World?=");
|
assert_eq!(
|
||||||
|
merge_contiguous_encoded_words(s),
|
||||||
|
"=?UTF-8?Q?Hello_World?="
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── normalize_subject ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_subject_none() {
|
||||||
|
assert_eq!(normalize_subject(None), "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
44
crates/core/src/error/code.rs
Normal file
44
crates/core/src/error/code.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(u32)]
|
||||||
|
pub enum ErrorCode {
|
||||||
|
// Client-side errors (10000–10999)
|
||||||
|
InvalidParameter = 10000,
|
||||||
|
MissingConfiguration = 10020,
|
||||||
|
Incompatible = 10030,
|
||||||
|
PayloadTooLarge = 10070,
|
||||||
|
RequestTimeout = 10080,
|
||||||
|
MethodNotAllowed = 10090,
|
||||||
|
|
||||||
|
// Authentication and authorization errors (20000–20999)
|
||||||
|
PermissionDenied = 20000,
|
||||||
|
AccountDisabled = 20010,
|
||||||
|
Forbidden = 20020,
|
||||||
|
OAuth2ItemDisabled = 20050,
|
||||||
|
MissingRefreshToken = 20060,
|
||||||
|
|
||||||
|
// Resource errors (30000–30999)
|
||||||
|
ResourceNotFound = 30000,
|
||||||
|
TooManyRequest = 30020,
|
||||||
|
AlreadyExists = 30030,
|
||||||
|
|
||||||
|
// Network connection errors (40000–40999)
|
||||||
|
NetworkError = 40000,
|
||||||
|
ConnectionTimeout = 40010,
|
||||||
|
ConnectionPoolTimeout = 40020,
|
||||||
|
HttpResponseError = 40030,
|
||||||
|
|
||||||
|
// Mail service errors (50000–50999)
|
||||||
|
ImapCommandFailed = 50000,
|
||||||
|
ImapAuthenticationFailed = 50010,
|
||||||
|
ImapUnexpectedResult = 50020,
|
||||||
|
AutoconfigFetchFailed = 50060,
|
||||||
|
// Internal system errors (70000–70999)
|
||||||
|
InternalError = 70000,
|
||||||
|
UnhandledPoemError = 70010,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorCode {
|
||||||
|
pub fn to_u32(&self) -> u32 {
|
||||||
|
*self as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
19
crates/core/src/error/mod.rs
Normal file
19
crates/core/src/error/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use snafu::{Location, Snafu};
|
||||||
|
|
||||||
|
use crate::error::code::ErrorCode;
|
||||||
|
|
||||||
|
pub mod code;
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(visibility(pub))]
|
||||||
|
pub enum BichonError {
|
||||||
|
#[snafu(display("{message}"))]
|
||||||
|
Generic {
|
||||||
|
message: String,
|
||||||
|
#[snafu(implicit)]
|
||||||
|
location: Location,
|
||||||
|
code: ErrorCode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type BichonResult<T, E = BichonError> = std::result::Result<T, E>;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
// 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::code::ErrorCode;
|
use crate::error::code::ErrorCode;
|
||||||
use crate::modules::imap::session::SessionStream;
|
use crate::imap::session::SessionStream;
|
||||||
use crate::{modules::error::BichonResult, raise_error};
|
use crate::{error::BichonResult, raise_error};
|
||||||
use async_imap::types::Capability;
|
use async_imap::types::Capability;
|
||||||
use async_imap::{types::Capabilities, Session};
|
use async_imap::{types::Capabilities, Session};
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,14 +16,14 @@
|
|||||||
// 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::account::entity::Encryption;
|
use crate::account::entity::Encryption;
|
||||||
use crate::modules::error::code::ErrorCode;
|
use crate::error::code::ErrorCode;
|
||||||
use crate::modules::error::BichonResult;
|
use crate::error::BichonResult;
|
||||||
use crate::modules::imap::session::SessionStream;
|
use crate::imap::session::SessionStream;
|
||||||
use crate::modules::imap::stats::StatsWrapper;
|
use crate::imap::stats::StatsWrapper;
|
||||||
use crate::modules::utils::net::establish_tcp_connection_with_timeout;
|
use crate::utils::net::establish_tcp_connection_with_timeout;
|
||||||
use crate::modules::utils::net::establish_tls_connection;
|
use crate::utils::net::establish_tls_connection;
|
||||||
use crate::modules::utils::tls::establish_tls_stream;
|
use crate::utils::tls::establish_tls_stream;
|
||||||
use crate::raise_error;
|
use crate::raise_error;
|
||||||
use async_imap::Client as ImapClient;
|
use async_imap::Client as ImapClient;
|
||||||
use async_imap::Session as ImapSession;
|
use async_imap::Session as ImapSession;
|
||||||
297
crates/core/src/imap/executor.rs
Normal file
297
crates/core/src/imap/executor.rs
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::account::migration::AccountModel;
|
||||||
|
use crate::account::state::{DownloadState, FolderStatus};
|
||||||
|
use crate::cache::imap::download::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE};
|
||||||
|
use crate::cache::imap::mailbox::MailBox;
|
||||||
|
use crate::envelope::extractor::extract_envelope_and_store_it;
|
||||||
|
use crate::error::code::ErrorCode;
|
||||||
|
use crate::imap::session::SessionStream;
|
||||||
|
use crate::raise_error;
|
||||||
|
use crate::{error::BichonResult, imap::manager::ImapConnectionManager};
|
||||||
|
use async_imap::types::Name;
|
||||||
|
use async_imap::Session;
|
||||||
|
use futures::TryStreamExt;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
const BODY_FETCH_COMMAND: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[])";
|
||||||
|
|
||||||
|
pub struct ImapExecutor;
|
||||||
|
|
||||||
|
impl ImapExecutor {
|
||||||
|
pub async fn list_all_mailboxes(
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
) -> BichonResult<Vec<Name>> {
|
||||||
|
let list = session
|
||||||
|
.list(Some(""), Some("*"))
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||||
|
let result = list
|
||||||
|
.try_collect::<Vec<Name>>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn uid_search(
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
mailbox_name: &str,
|
||||||
|
query: &str,
|
||||||
|
) -> BichonResult<HashSet<u32>> {
|
||||||
|
session
|
||||||
|
.examine(mailbox_name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||||
|
let result = session
|
||||||
|
.uid_search(query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn append(
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
mailbox_name: impl AsRef<str>,
|
||||||
|
flags: Option<&str>,
|
||||||
|
internaldate: Option<&str>,
|
||||||
|
content: impl AsRef<[u8]>,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
session
|
||||||
|
.append(mailbox_name, flags, internaldate, content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_new_mail(
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
account: &AccountModel,
|
||||||
|
mailbox: &MailBox,
|
||||||
|
start_uid: u64,
|
||||||
|
before: Option<&str>,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
assert!(start_uid > 0, "start_uid must be greater than 0");
|
||||||
|
|
||||||
|
let query = match before {
|
||||||
|
Some(date) => format!("UID {start_uid}:* BEFORE {date}"),
|
||||||
|
None => format!("UID {start_uid}:*"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let uid_list = match Self::uid_search(session, &mailbox.encoded_name(), &query).await {
|
||||||
|
Ok(uid_list) => uid_list,
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("UID search failed in [{}]: {:#?}", mailbox.name, e);
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some(err_msg.clone()),
|
||||||
|
)?;
|
||||||
|
DownloadState::append_session_error(account.id, err_msg)?;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let len = uid_list.len();
|
||||||
|
if len == 0 {
|
||||||
|
let msg = match before {
|
||||||
|
Some(date) => format!("No emails found before {}.", date),
|
||||||
|
None => "No new emails found.".into(),
|
||||||
|
};
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Success,
|
||||||
|
Some(msg),
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"[account {}][mailbox {}] {} envelopes need to be fetched",
|
||||||
|
account.id, mailbox.name, len
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
|
||||||
|
uid_vec.sort();
|
||||||
|
let uid_batches = generate_uid_sequence_hashset(
|
||||||
|
uid_vec,
|
||||||
|
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let mut current_processed = 0u64;
|
||||||
|
let mut has_error_or_cancel = false;
|
||||||
|
for (index, batch) in uid_batches.into_iter().enumerate() {
|
||||||
|
if token.is_cancelled() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match Self::uid_batch_retrieve_emails(
|
||||||
|
session,
|
||||||
|
account.id,
|
||||||
|
mailbox.id,
|
||||||
|
&batch.0,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
current_processed += batch.1;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
len as u64,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Downloading,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Batch {} failed: {:#?}", index, e);
|
||||||
|
DownloadState::append_session_error(account.id, err_msg.clone())?;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
len as u64,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some(err_msg),
|
||||||
|
)?;
|
||||||
|
has_error_or_cancel = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_error_or_cancel {
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
len as u64,
|
||||||
|
current_processed,
|
||||||
|
FolderStatus::Success,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn batch_retrieve_emails(
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
account_id: u64,
|
||||||
|
mailbox_id: u64,
|
||||||
|
total: u64,
|
||||||
|
page: u64,
|
||||||
|
page_size: u64,
|
||||||
|
encoded_mailbox_name: &str,
|
||||||
|
desc: bool,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<usize> {
|
||||||
|
assert!(page > 0, "Page number must be greater than 0");
|
||||||
|
assert!(page_size > 0, "Page size must be greater than 0");
|
||||||
|
|
||||||
|
let (start, end) = if desc {
|
||||||
|
// Fetch messages starting from the newest (descending order)
|
||||||
|
let end = total.saturating_sub((page - 1) * page_size);
|
||||||
|
if end == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
// Calculate start as end - page_size + 1 to avoid off-by-one errors
|
||||||
|
let start = end.saturating_sub(page_size - 1).max(1);
|
||||||
|
(start, end)
|
||||||
|
} else {
|
||||||
|
// Fetch messages starting from the oldest (ascending order)
|
||||||
|
let start = (page - 1) * page_size + 1;
|
||||||
|
if start > total {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
// Calculate end, capped by the total number of messages
|
||||||
|
let end = (start + page_size - 1).min(total);
|
||||||
|
(start, end)
|
||||||
|
};
|
||||||
|
|
||||||
|
let sequence_set = format!("{}:{}", start, end);
|
||||||
|
info!(
|
||||||
|
"Fetching mailbox '{}' messages: sequence {} (page {}, page_size {}, desc={})",
|
||||||
|
encoded_mailbox_name, sequence_set, page, page_size, desc
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stream = session
|
||||||
|
.fetch(sequence_set.as_str(), BODY_FETCH_COMMAND)
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some(fetch) = stream
|
||||||
|
.try_next()
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||||
|
{
|
||||||
|
if token.is_cancelled() {
|
||||||
|
tracing::info!("Account {}: UID fetch stream interrupted.", account_id);
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Stream cancelled".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
));
|
||||||
|
}
|
||||||
|
extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn uid_batch_retrieve_emails(
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
account_id: u64,
|
||||||
|
mailbox_id: u64,
|
||||||
|
uid_set: &str,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<()> {
|
||||||
|
let mut stream = session
|
||||||
|
.uid_fetch(uid_set, BODY_FETCH_COMMAND)
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||||
|
while let Some(fetch) = stream
|
||||||
|
.try_next()
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||||
|
{
|
||||||
|
if token.is_cancelled() {
|
||||||
|
tracing::info!("Account {}: UID fetch stream interrupted.", account_id);
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Stream cancelled".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
));
|
||||||
|
}
|
||||||
|
extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_connection(
|
||||||
|
account_id: u64,
|
||||||
|
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||||
|
ImapConnectionManager::build(account_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,38 +16,23 @@
|
|||||||
// 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::account::dispatcher::STATUS_DISPATCHER;
|
use crate::account::entity::AuthType;
|
||||||
use crate::modules::account::entity::AuthType;
|
use crate::account::migration::{AccountModel, AccountType};
|
||||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
use crate::error::code::ErrorCode;
|
||||||
use crate::modules::error::code::ErrorCode;
|
use crate::error::BichonResult;
|
||||||
use crate::modules::error::BichonResult;
|
use crate::imap::capabilities::{capability_to_string, check_capabilities, fetch_capabilities};
|
||||||
use crate::modules::imap::capabilities::{
|
use crate::imap::client::Client;
|
||||||
capability_to_string, check_capabilities, fetch_capabilities,
|
use crate::imap::oauth2::OAuth2;
|
||||||
};
|
use crate::imap::session::SessionStream;
|
||||||
use crate::modules::imap::client::Client;
|
use crate::oauth2::token::OAuth2AccessToken;
|
||||||
use crate::modules::imap::oauth2::OAuth2;
|
|
||||||
use crate::modules::imap::session::SessionStream;
|
|
||||||
use crate::modules::oauth2::token::OAuth2AccessToken;
|
|
||||||
use crate::{bichon_version, decrypt, raise_error};
|
use crate::{bichon_version, decrypt, raise_error};
|
||||||
use async_imap::Session;
|
use async_imap::Session;
|
||||||
use tracing::error;
|
use tracing::{error, warn};
|
||||||
|
|
||||||
#[derive(Debug)]
|
pub struct ImapConnectionManager;
|
||||||
pub struct ImapConnectionManager {
|
|
||||||
pub account_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImapConnectionManager {
|
impl ImapConnectionManager {
|
||||||
pub fn new(account_id: u64) -> Self {
|
async fn create_client(account: &AccountModel) -> BichonResult<Client> {
|
||||||
Self { account_id }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn fetch_account(&self) -> BichonResult<AccountModel> {
|
|
||||||
// Fetch the account entity in non-test environment
|
|
||||||
AccountModel::get(self.account_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_client(&self, account: &AccountModel) -> BichonResult<Client> {
|
|
||||||
assert_eq!(account.account_type, AccountType::IMAP);
|
assert_eq!(account.account_type, AccountType::IMAP);
|
||||||
let imap = account.imap.as_ref().unwrap();
|
let imap = account.imap.as_ref().unwrap();
|
||||||
Client::connection(
|
Client::connection(
|
||||||
@@ -61,13 +46,12 @@ impl ImapConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn authenticate(
|
async fn authenticate(
|
||||||
&self,
|
|
||||||
client: Client,
|
client: Client,
|
||||||
account: &AccountModel,
|
account: &AccountModel,
|
||||||
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||||
assert_eq!(account.account_type, AccountType::IMAP);
|
assert_eq!(account.account_type, AccountType::IMAP);
|
||||||
let imap = account.imap.as_ref().unwrap();
|
let imap = account.imap.as_ref().unwrap();
|
||||||
let username = account.name.clone().unwrap_or(account.email.clone());
|
let login_name = account.login_name.clone().unwrap_or(account.email.clone());
|
||||||
match &imap.auth.auth_type {
|
match &imap.auth.auth_type {
|
||||||
AuthType::Password => {
|
AuthType::Password => {
|
||||||
let password = &imap.auth.password.clone().ok_or_else(|| {
|
let password = &imap.auth.password.clone().ok_or_else(|| {
|
||||||
@@ -78,16 +62,16 @@ impl ImapConnectionManager {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let password = decrypt!(&password)?;
|
let password = decrypt!(&password)?;
|
||||||
client.login(&username, &password).await.map_err(|e| {
|
client.login(&login_name, &password).await.map_err(|e| {
|
||||||
error!(
|
error!(
|
||||||
"IMAP password auth failed for username '{}': {}",
|
"IMAP password auth failed for username '{}': {}",
|
||||||
username, e
|
login_name, e
|
||||||
);
|
);
|
||||||
e
|
e
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
AuthType::OAuth2 => {
|
AuthType::OAuth2 => {
|
||||||
let record = OAuth2AccessToken::get(self.account_id).await?;
|
let record = OAuth2AccessToken::get(account.id)?;
|
||||||
let access_token = record.and_then(|r| r.access_token).ok_or_else(|| {
|
let access_token = record.and_then(|r| r.access_token).ok_or_else(|| {
|
||||||
raise_error!(
|
raise_error!(
|
||||||
"Imap auth type is OAuth2, but OAuth2 authorization is not yet complete."
|
"Imap auth type is OAuth2, but OAuth2 authorization is not yet complete."
|
||||||
@@ -96,46 +80,36 @@ impl ImapConnectionManager {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
client
|
client
|
||||||
.authenticate(OAuth2::new(username.clone(), access_token))
|
.authenticate(OAuth2::new(login_name.clone(), access_token))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
error!("IMAP OAuth2 auth failed for username '{}': {}", username, e);
|
error!(
|
||||||
|
"IMAP OAuth2 auth failed for username '{}': {}",
|
||||||
|
login_name, e
|
||||||
|
);
|
||||||
e
|
e
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build(&self) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
pub async fn build(account_id: u64) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||||
let account = self.fetch_account().await?;
|
let account = AccountModel::get(account_id)?;
|
||||||
let client = match self.create_client(&account).await {
|
let client = match Self::create_client(&account).await {
|
||||||
Ok(client) => client,
|
Ok(client) => client,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
error!(
|
error!(
|
||||||
"Failed to create IMAP {}'s client: {:#?}",
|
"Failed to create IMAP {}'s client: {:#?}",
|
||||||
&account.email, error
|
&account.email, error
|
||||||
);
|
);
|
||||||
STATUS_DISPATCHER
|
|
||||||
.append_error(
|
|
||||||
self.account_id,
|
|
||||||
format!("imap client connect error: {:#?}", error),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut session = match self.authenticate(client, &account).await {
|
let mut session = match Self::authenticate(client, &account).await {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
error!("Failed to authenticate IMAP session: {:#?}", error);
|
error!("Failed to authenticate IMAP session: {:#?}", error);
|
||||||
|
|
||||||
STATUS_DISPATCHER
|
|
||||||
.append_error(
|
|
||||||
self.account_id,
|
|
||||||
format!("imap client authenticate error: {:#?}", error),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -143,39 +117,27 @@ impl ImapConnectionManager {
|
|||||||
match fetch_capabilities(&mut session).await {
|
match fetch_capabilities(&mut session).await {
|
||||||
Ok(capabilities) => {
|
Ok(capabilities) => {
|
||||||
let to_save: Vec<String> = capabilities.iter().map(capability_to_string).collect();
|
let to_save: Vec<String> = capabilities.iter().map(capability_to_string).collect();
|
||||||
AccountModel::update_capabilities(self.account_id, to_save).await?;
|
AccountModel::update_capabilities(account_id, to_save)?;
|
||||||
if let Err(error) = check_capabilities(&capabilities) {
|
if let Err(error) = check_capabilities(&capabilities) {
|
||||||
error!("Failed to check IMAP capabilities: {:#?}", error);
|
error!("Failed to check IMAP capabilities: {:#?}", error);
|
||||||
STATUS_DISPATCHER
|
|
||||||
.append_error(
|
|
||||||
self.account_id,
|
|
||||||
format!("imap client check capabilities error: {:#?}", error),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if capabilities.has_str("ID") || capabilities.has_str("id") {
|
if capabilities.has_str("ID") || capabilities.has_str("id") {
|
||||||
session
|
if let Err(e) = session
|
||||||
.id([
|
.id([
|
||||||
("name", Some("bichon")),
|
("name", Some("bichon")),
|
||||||
("version", Some(bichon_version!())),
|
("version", Some(bichon_version!())),
|
||||||
("vendor", Some("rustmailer")),
|
("vendor", Some("rustmailer")),
|
||||||
])
|
])
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
{
|
||||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
warn!("IMAP ID command failed (ignored): {:#?}", e);
|
||||||
})?;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
error!("Failed to fetch IMAP capabilities: {:#?}", error);
|
error!("Failed to fetch IMAP capabilities: {:#?}", error);
|
||||||
STATUS_DISPATCHER
|
|
||||||
.append_error(
|
|
||||||
self.account_id,
|
|
||||||
format!("imap client fetch capabilities error: {:#?}", error),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -22,7 +22,6 @@ pub mod client;
|
|||||||
pub mod executor;
|
pub mod executor;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
pub mod oauth2;
|
pub mod oauth2;
|
||||||
pub mod pool;
|
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -23,7 +23,7 @@ use std::task::{Context, Poll};
|
|||||||
|
|
||||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||||
|
|
||||||
use crate::modules::imap::session::SessionStream;
|
use crate::imap::session::SessionStream;
|
||||||
|
|
||||||
pub struct StatsWrapper<T> {
|
pub struct StatsWrapper<T> {
|
||||||
inner: T,
|
inner: T,
|
||||||
203
crates/core/src/imap/tests.rs
Normal file
203
crates/core/src/imap/tests.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
base64_encode_url_safe,
|
||||||
|
{account::entity::Encryption, imap::client::Client},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn testxx() {
|
||||||
|
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
|
||||||
|
.unwrap();
|
||||||
|
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None, false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut session = client.login("xx@zohomail.com", "xxx").await.unwrap();
|
||||||
|
session.select("INBOX").await.unwrap();
|
||||||
|
let result = session.uid_search("LARGER 1024").await.unwrap();
|
||||||
|
println!("{:#?}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test1() {
|
||||||
|
let path = r"C:\Users\polly\Downloads\test.eml";
|
||||||
|
let eml_data = std::fs::read(path).unwrap();
|
||||||
|
let input = base64_encode_url_safe!(eml_data);
|
||||||
|
let message = MessageParser::default().parse(&input).unwrap();
|
||||||
|
let parts = message.parts;
|
||||||
|
for part in parts {
|
||||||
|
println!("{}", part.is_message());
|
||||||
|
println!("{}", part.is_multipart());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test2() {
|
||||||
|
const MESSAGE: &str = r#"From: Art Vandelay <art@vandelay.com> (Vandelay Industries)
|
||||||
|
X-Gmail-Labels: =?UTF-8?Q?Archiv=C3=A9s,Envoy=C3=A9?=
|
||||||
|
To: "Colleagues": "James Smythe" <james@vandelay.com>; Friends:
|
||||||
|
jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?= <john@example.com>;
|
||||||
|
Date: Sat, 20 Nov 2021 14:22:01 -0800
|
||||||
|
Subject: =?utf-8?B?SnVzdCAxNSBkYXlzIGxlZnQgdG8gdmlzaXQgTkFSTklBISDinYTvuI/wn462?=
|
||||||
|
Content-Type: multipart/mixed; boundary="festivus";
|
||||||
|
|
||||||
|
--festivus
|
||||||
|
Content-Type: text/html; charset="us-ascii"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
PGh0bWw+PHA+SSB3YXMgdGhpbmtpbmcgYWJvdXQgcXVpdHRpbmcgdGhlICZsZHF1bztle
|
||||||
|
HBvcnRpbmcmcmRxdW87IHRvIGZvY3VzIGp1c3Qgb24gdGhlICZsZHF1bztpbXBvcnRpbm
|
||||||
|
cmcmRxdW87LDwvcD48cD5idXQgdGhlbiBJIHRob3VnaHQsIHdoeSBub3QgZG8gYm90aD8
|
||||||
|
gJiN4MjYzQTs8L3A+PC9odG1sPg==
|
||||||
|
--festivus
|
||||||
|
Content-Type: message/rfc822
|
||||||
|
|
||||||
|
From: "Cosmo Kramer" <kramer@kramerica.com>
|
||||||
|
Subject: Exporting my book about coffee tables
|
||||||
|
Content-Type: multipart/mixed; boundary="giddyup";
|
||||||
|
|
||||||
|
--giddyup
|
||||||
|
Content-Type: text/plain; charset="utf-16"
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
|
||||||
|
=FF=FE=0C!5=D8"=DD5=D8)=DD5=D8-=DD =005=D8*=DD5=D8"=DD =005=D8"=
|
||||||
|
=DD5=D85=DD5=D8-=DD5=D8,=DD5=D8/=DD5=D81=DD =005=D8*=DD5=D86=DD =
|
||||||
|
=005=D8=1F=DD5=D8,=DD5=D8,=DD5=D8(=DD =005=D8-=DD5=D8)=DD5=D8"=
|
||||||
|
=DD5=D8=1E=DD5=D80=DD5=D8"=DD!=00
|
||||||
|
--giddyup
|
||||||
|
Content-Type: image/gif; name*1="about "; name*0="Book ";
|
||||||
|
name*2*=utf-8''%e2%98%95 tables.gif
|
||||||
|
Content-Transfer-Encoding: Base64
|
||||||
|
Content-Disposition: attachment
|
||||||
|
|
||||||
|
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
|
||||||
|
--giddyup--
|
||||||
|
--festivus--
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let message = MessageParser::default().parse(MESSAGE).unwrap();
|
||||||
|
let header = message.header_raw("X-Gmail-Labels").unwrap().as_bytes();
|
||||||
|
|
||||||
|
let data = MessageStream::new(header)
|
||||||
|
.parse_unstructured()
|
||||||
|
.unwrap_text()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
println!("{}", data);
|
||||||
|
// RFC2047 support for encoded text in message readers
|
||||||
|
//println!("{}", message.subject().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bulk_attachment_stripping_blake3() {
|
||||||
|
let path = r"C:\Users\polly\Downloads\test666.eml";
|
||||||
|
let input = std::fs::read(path).expect("Failed to read EML file");
|
||||||
|
|
||||||
|
// 1. Initial Parse
|
||||||
|
let message = MessageParser::default()
|
||||||
|
.parse(&input)
|
||||||
|
.expect("Failed to parse EML");
|
||||||
|
|
||||||
|
// 2. Collect and cast types explicitly
|
||||||
|
// We map the u32 offsets to usize here to satisfy the Vec<(usize, usize, ...)> requirement
|
||||||
|
let mut attachments: Vec<(usize, usize, Vec<u8>)> = message
|
||||||
|
.attachments()
|
||||||
|
.map(|att| {
|
||||||
|
(
|
||||||
|
att.raw_body_offset() as usize,
|
||||||
|
att.raw_end_offset() as usize,
|
||||||
|
att.contents().to_vec(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 3. Sort by offset descending (BACK TO FRONT)
|
||||||
|
// This ensures that modifying the file length doesn't invalidate earlier offsets
|
||||||
|
attachments.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
|
||||||
|
let mut modified_eml = input.clone();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Processing {} attachments in reverse order...",
|
||||||
|
attachments.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (start, end, raw_content) in attachments {
|
||||||
|
// Calculate BLAKE3 Hash
|
||||||
|
let hash = blake3::hash(&raw_content).to_hex().to_string();
|
||||||
|
let placeholder = format!("STRIPPED_BLAKE3:{}", hash);
|
||||||
|
let placeholder_bytes = placeholder.as_bytes();
|
||||||
|
|
||||||
|
// Perform the byte surgery
|
||||||
|
let mut new_buffer =
|
||||||
|
Vec::with_capacity(modified_eml.len() - (end - start) + placeholder_bytes.len());
|
||||||
|
new_buffer.extend_from_slice(&modified_eml[..start]);
|
||||||
|
new_buffer.extend_from_slice(placeholder_bytes);
|
||||||
|
new_buffer.extend_from_slice(&modified_eml[end..]);
|
||||||
|
|
||||||
|
modified_eml = new_buffer;
|
||||||
|
println!(
|
||||||
|
"Stripped attachment at offset {}. New hash: {}",
|
||||||
|
start, hash
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write("test.eml", &modified_eml).unwrap();
|
||||||
|
|
||||||
|
// 4. Final Verification
|
||||||
|
let final_message = MessageParser::default().parse(&modified_eml).unwrap();
|
||||||
|
|
||||||
|
println!("\n--- Verification Report ---");
|
||||||
|
for (i, att) in final_message.attachments().enumerate() {
|
||||||
|
let content = String::from_utf8_lossy(att.contents());
|
||||||
|
println!(
|
||||||
|
"Part [{}]: {}, Content: {}",
|
||||||
|
i,
|
||||||
|
att.attachment_name().unwrap_or("unknown"),
|
||||||
|
content
|
||||||
|
);
|
||||||
|
assert!(content.contains("STRIPPED_BLAKE3:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("✅ All attachments replaced successfully from back to front.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_667() {
|
||||||
|
let path = r"C:\Users\polly\Downloads\test777.eml";
|
||||||
|
let input = std::fs::read(path).expect("Failed to read EML file");
|
||||||
|
|
||||||
|
let message = MessageParser::default()
|
||||||
|
.parse(&input)
|
||||||
|
.expect("Failed to parse EML");
|
||||||
|
|
||||||
|
for att in message.attachments() {
|
||||||
|
println!("name: {:#?}", att.attachment_name());
|
||||||
|
println!("content_type: {:#?}", att.content_type());
|
||||||
|
println!("is_message: {:#?}", att.is_message());
|
||||||
|
println!("content_disposition: {:#?}", att.content_disposition());
|
||||||
|
println!(
|
||||||
|
"content_transfer_encoding: {:#?}",
|
||||||
|
att.content_transfer_encoding()
|
||||||
|
);
|
||||||
|
println!("content_id: {:#?}", att.content_id());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,42 @@
|
|||||||
use poem_openapi::Object;
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
//use poem_openapi::Object;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tantivy::doc;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
base64_decode_url_safe,
|
base64_decode_url_safe,
|
||||||
modules::{
|
{
|
||||||
account::migration::{AccountModel, AccountType},
|
account::migration::{AccountModel, AccountType},
|
||||||
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
|
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
|
||||||
envelope::extractor::extract_envelope_from_eml,
|
envelope::extractor::extract_envelope_from_eml,
|
||||||
error::{code::ErrorCode, BichonResult},
|
error::{BichonResult, code::ErrorCode},
|
||||||
indexer::{
|
|
||||||
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
|
||||||
schema::SchemaTools,
|
|
||||||
},
|
|
||||||
utils::create_hash,
|
utils::create_hash,
|
||||||
},
|
},
|
||||||
raise_error,
|
raise_error,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
/// Skip individual emails larger than this after decoding (100 MB).
|
||||||
|
const MAX_SINGLE_EML_BYTES: usize = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct BatchEmlRequest {
|
pub struct BatchEmlRequest {
|
||||||
pub account_id: u64,
|
pub account_id: u64,
|
||||||
pub mail_folder: String,
|
pub mail_folder: String,
|
||||||
@@ -26,7 +44,8 @@ pub struct BatchEmlRequest {
|
|||||||
pub emls: Vec<String>,
|
pub emls: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct FailedEmlDetail {
|
pub struct FailedEmlDetail {
|
||||||
/// The 0-based index of the failed EML in the request list
|
/// The 0-based index of the failed EML in the request list
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
@@ -34,7 +53,8 @@ pub struct FailedEmlDetail {
|
|||||||
pub error_message: String,
|
pub error_message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct BatchEmlResult {
|
pub struct BatchEmlResult {
|
||||||
/// Total number of emails processed
|
/// Total number of emails processed
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
@@ -49,8 +69,8 @@ pub struct BatchEmlResult {
|
|||||||
pub struct ImportEmls;
|
pub struct ImportEmls;
|
||||||
|
|
||||||
impl ImportEmls {
|
impl ImportEmls {
|
||||||
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
|
pub async fn do_import(mut request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
|
||||||
let account = AccountModel::check_account_exists(request.account_id).await?;
|
let account = AccountModel::check_account_exists(request.account_id)?;
|
||||||
|
|
||||||
if !account.enabled {
|
if !account.enabled {
|
||||||
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
|
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
|
||||||
@@ -58,7 +78,7 @@ impl ImportEmls {
|
|||||||
|
|
||||||
let mailbox_id = match account.account_type {
|
let mailbox_id = match account.account_type {
|
||||||
AccountType::IMAP => {
|
AccountType::IMAP => {
|
||||||
let all_mailboxes = MailBox::list_all(account.id).await?;
|
let all_mailboxes = MailBox::list_all(account.id)?;
|
||||||
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
|
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
|
||||||
|
|
||||||
match mailbox {
|
match mailbox {
|
||||||
@@ -88,18 +108,18 @@ impl ImportEmls {
|
|||||||
};
|
};
|
||||||
let mailbox_id = mailbox.id;
|
let mailbox_id = mailbox.id;
|
||||||
// Upsert the mailbox, creating it if it doesn't exist
|
// Upsert the mailbox, creating it if it doesn't exist
|
||||||
MailBox::batch_upsert(&[mailbox]).await?;
|
MailBox::batch_upsert(&[mailbox])?;
|
||||||
mailbox_id
|
mailbox_id
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let fields = SchemaTools::eml_fields();
|
|
||||||
let account_id = account.id;
|
let account_id = account.id;
|
||||||
let mut success_count = 0;
|
let mut success_count = 0;
|
||||||
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
|
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
|
||||||
|
|
||||||
let total = request.emls.len();
|
let total = request.emls.len();
|
||||||
for (index, eml_base64) in request.emls.into_iter().enumerate() {
|
let mut index: usize = 0;
|
||||||
|
while let Some(eml_base64) = request.emls.pop() {
|
||||||
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) => {
|
||||||
@@ -110,12 +130,31 @@ impl ImportEmls {
|
|||||||
index,
|
index,
|
||||||
error_message: error_msg,
|
error_message: error_msg,
|
||||||
});
|
});
|
||||||
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// eml_base64 string dropped here — frees base64 memory before parsing
|
||||||
|
|
||||||
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
|
if decoded.len() > MAX_SINGLE_EML_BYTES {
|
||||||
Ok(env) => env,
|
let size_mb = decoded.len() as f64 / 1024.0 / 1024.0;
|
||||||
|
let error_msg = format!(
|
||||||
|
"Email at index {} is {:.1} MB (limit 50 MB). Skipping.",
|
||||||
|
index, size_mb,
|
||||||
|
);
|
||||||
|
tracing::warn!("{}", error_msg);
|
||||||
|
failed_details.push(FailedEmlDetail {
|
||||||
|
index,
|
||||||
|
error_message: error_msg,
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match extract_envelope_from_eml(&decoded, account_id, mailbox_id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
success_count += 1;
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error_msg = format!(
|
let error_msg = format!(
|
||||||
"Failed to extract envelope from EML at index {}: {:?}",
|
"Failed to extract envelope from EML at index {}: {:?}",
|
||||||
@@ -126,27 +165,11 @@ impl ImportEmls {
|
|||||||
index,
|
index,
|
||||||
error_message: error_msg,
|
error_message: error_msg,
|
||||||
});
|
});
|
||||||
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
index += 1;
|
||||||
ENVELOPE_INDEX_MANAGER
|
|
||||||
.add_document(envelope.id, envelope.to_document(mailbox_id).unwrap())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
EML_INDEX_MANAGER
|
|
||||||
.add_document(
|
|
||||||
envelope.id,
|
|
||||||
doc!(
|
|
||||||
fields.f_id => envelope.id,
|
|
||||||
fields.f_account_id => account_id,
|
|
||||||
fields.f_mailbox_id => mailbox_id,
|
|
||||||
fields.f_eml => decoded
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
success_count += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let failed_count = failed_details.len();
|
let failed_count = failed_details.len();
|
||||||
24
crates/core/src/lib.rs
Normal file
24
crates/core/src/lib.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
pub mod account;
|
||||||
|
pub mod admin;
|
||||||
|
pub mod autoconfig;
|
||||||
|
pub mod cache;
|
||||||
|
pub mod common;
|
||||||
|
pub mod context;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod database;
|
||||||
|
pub mod envelope;
|
||||||
|
pub mod error;
|
||||||
|
pub mod imap;
|
||||||
|
pub mod import;
|
||||||
|
pub mod logger;
|
||||||
|
pub mod mailbox;
|
||||||
|
pub mod message;
|
||||||
|
pub mod migrate;
|
||||||
|
pub mod oauth2;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod store;
|
||||||
|
pub mod tasks;
|
||||||
|
pub mod token;
|
||||||
|
pub mod users;
|
||||||
|
pub mod utils;
|
||||||
|
pub mod version;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,10 +16,9 @@
|
|||||||
// 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::logger::LocalTimer;
|
||||||
use crate::modules::logger::{validate_log_level, LocalTimer};
|
use crate::settings::cli::SETTINGS;
|
||||||
use crate::modules::settings::cli::SETTINGS;
|
use crate::settings::dir::DATA_DIR_MANAGER;
|
||||||
use crate::modules::settings::dir::DATA_DIR_MANAGER;
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
@@ -30,9 +29,7 @@ use tracing_subscriber::layer::SubscriberExt;
|
|||||||
|
|
||||||
pub static LOG_WORKER_GUARD: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
|
pub static LOG_WORKER_GUARD: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
|
||||||
|
|
||||||
pub fn setup_file_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
pub fn setup_file_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||||
validate_log_level(&SETTINGS.bichon_log_level);
|
|
||||||
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
|
|
||||||
let with_ansi = SETTINGS.bichon_ansi_logs;
|
let with_ansi = SETTINGS.bichon_ansi_logs;
|
||||||
|
|
||||||
let (server_nonb, server_guard) = server_log_writer();
|
let (server_nonb, server_guard) = server_log_writer();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,11 +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::modules::logger::file::setup_file_logger;
|
use crate::logger::file::setup_file_logger;
|
||||||
use crate::modules::settings::cli::SETTINGS;
|
use crate::settings::cli::SETTINGS;
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use std::process;
|
use std::process;
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
|
use tracing_log::LogTracer;
|
||||||
use tracing_subscriber::fmt::{format::Writer, time::FormatTime};
|
use tracing_subscriber::fmt::{format::Writer, time::FormatTime};
|
||||||
|
|
||||||
mod file;
|
mod file;
|
||||||
@@ -34,16 +35,18 @@ impl FormatTime for LocalTimer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn initialize_logging() {
|
pub fn initialize_logging() {
|
||||||
|
let level = validate_log_level(&SETTINGS.bichon_log_level);
|
||||||
|
if matches!(level, Level::DEBUG) || matches!(level, Level::TRACE) {
|
||||||
|
LogTracer::init().unwrap();
|
||||||
|
}
|
||||||
if SETTINGS.bichon_log_to_file {
|
if SETTINGS.bichon_log_to_file {
|
||||||
setup_file_logger().unwrap();
|
setup_file_logger(level).unwrap();
|
||||||
} else {
|
} else {
|
||||||
setup_stdout_logger().unwrap();
|
setup_stdout_logger(level).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
fn setup_stdout_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||||
validate_log_level(&SETTINGS.bichon_log_level);
|
|
||||||
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
|
|
||||||
let with_ansi = SETTINGS.bichon_ansi_logs;
|
let with_ansi = SETTINGS.bichon_ansi_logs;
|
||||||
|
|
||||||
let format = tracing_subscriber::fmt::format()
|
let format = tracing_subscriber::fmt::format()
|
||||||
@@ -61,13 +64,16 @@ fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultErro
|
|||||||
tracing::subscriber::set_global_default(subscriber)
|
tracing::subscriber::set_global_default(subscriber)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_log_level(value: &String) {
|
fn validate_log_level(value: &String) -> Level {
|
||||||
if value.parse::<Level>().is_err() {
|
match value.parse::<Level>() {
|
||||||
eprintln!(
|
Ok(level) => level,
|
||||||
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
|
Err(_) => {
|
||||||
The log level you currently specified is 'rustmailer_log_level'='{}'",
|
eprintln!(
|
||||||
value
|
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
|
||||||
);
|
The log level you currently specified is 'rustmailer_log_level'='{}'",
|
||||||
process::exit(1);
|
value
|
||||||
|
);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
54
crates/core/src/mailbox/delete.rs
Normal file
54
crates/core/src/mailbox/delete.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cache::imap::mailbox::MailBox,
|
||||||
|
error::BichonResult,
|
||||||
|
store::tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResult<()> {
|
||||||
|
let mailbox = MailBox::get(mailbox_id)?;
|
||||||
|
|
||||||
|
let name = mailbox.name;
|
||||||
|
let delimiter = mailbox.delimiter.unwrap_or("/".to_owned());
|
||||||
|
let all_mailboxes = MailBox::list_all(account_id)?;
|
||||||
|
|
||||||
|
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)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
ENVELOPE_MANAGER
|
||||||
|
.delete_mailbox_envelopes(account_id, ids_to_delete.clone())
|
||||||
|
.await?;
|
||||||
|
ATTACHMENT_MANAGER
|
||||||
|
.delete_mailbox_attachments(account_id, ids_to_delete.clone())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
221
crates/core/src/mailbox/list.rs
Normal file
221
crates/core/src/mailbox/list.rs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::account::migration::{AccountModel, AccountType};
|
||||||
|
use crate::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
|
||||||
|
use crate::cache::imap::mailbox_cache::{self, FetchStatus};
|
||||||
|
use crate::error::code::ErrorCode;
|
||||||
|
use crate::error::BichonResult;
|
||||||
|
use crate::imap::executor::ImapExecutor;
|
||||||
|
use crate::imap::session::SessionStream;
|
||||||
|
use crate::raise_error;
|
||||||
|
use crate::utils::create_hash;
|
||||||
|
use async_imap::types::Name;
|
||||||
|
use async_imap::Session;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct MailboxListResponse {
|
||||||
|
pub mailboxes: Vec<MailBox>,
|
||||||
|
/// "ready" | "fetching" | "error"
|
||||||
|
pub status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub examined: Option<usize>,
|
||||||
|
pub total: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_account_mailboxes(
|
||||||
|
account_id: u64,
|
||||||
|
remote: bool,
|
||||||
|
) -> BichonResult<MailboxListResponse> {
|
||||||
|
let account = AccountModel::check_account_exists(account_id)?;
|
||||||
|
if remote {
|
||||||
|
if matches!(account.account_type, AccountType::IMAP) {
|
||||||
|
return Ok(remote_mailboxes(account_id).await);
|
||||||
|
} else {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"The 'remote' option can only be used with IMAP accounts.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mailboxes = MailBox::list_all(account_id)?;
|
||||||
|
return Ok(MailboxListResponse {
|
||||||
|
mailboxes,
|
||||||
|
status: "ready".into(),
|
||||||
|
error: None,
|
||||||
|
examined: None,
|
||||||
|
total: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_pending_response(status: &FetchStatus, error: Option<String>) -> MailboxListResponse {
|
||||||
|
let (examined, total) = match status {
|
||||||
|
FetchStatus::Fetching { examined, total } => (Some(*examined), Some(*total)),
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
MailboxListResponse {
|
||||||
|
mailboxes: vec![],
|
||||||
|
status: match status {
|
||||||
|
FetchStatus::Ready => "ready".into(),
|
||||||
|
FetchStatus::Fetching { .. } => "fetching".into(),
|
||||||
|
FetchStatus::Error(_) => "error".into(),
|
||||||
|
},
|
||||||
|
error,
|
||||||
|
examined,
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remote_mailboxes(account_id: u64) -> MailboxListResponse {
|
||||||
|
// Cache hit
|
||||||
|
if let Some(cached) = mailbox_cache::get(account_id).await {
|
||||||
|
return MailboxListResponse {
|
||||||
|
mailboxes: cached,
|
||||||
|
status: "ready".into(),
|
||||||
|
error: None,
|
||||||
|
examined: None,
|
||||||
|
total: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match mailbox_cache::fetch_status(account_id).await {
|
||||||
|
Some(status @ FetchStatus::Fetching { .. }) => {
|
||||||
|
return make_pending_response(&status, None);
|
||||||
|
}
|
||||||
|
Some(FetchStatus::Error(err)) => {
|
||||||
|
mailbox_cache::clear_fetch_state(account_id).await;
|
||||||
|
return MailboxListResponse {
|
||||||
|
mailboxes: vec![],
|
||||||
|
status: "error".into(),
|
||||||
|
error: Some(err),
|
||||||
|
examined: None,
|
||||||
|
total: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache, no fetch in progress — start background fetch
|
||||||
|
mailbox_cache::set_fetching(account_id).await;
|
||||||
|
spawn_fetch_task(account_id);
|
||||||
|
MailboxListResponse {
|
||||||
|
mailboxes: vec![],
|
||||||
|
status: "fetching".into(),
|
||||||
|
error: None,
|
||||||
|
examined: Some(0),
|
||||||
|
total: Some(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_fetch_task(account_id: u64) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match fetch_remote_with_progress(account_id).await {
|
||||||
|
Ok(mailboxes) => {
|
||||||
|
mailbox_cache::set(account_id, mailboxes).await;
|
||||||
|
mailbox_cache::set_fetch_ready(account_id).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
mailbox_cache::set_fetch_error(account_id, format!("{:#?}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_remote_with_progress(account_id: u64) -> BichonResult<Vec<MailBox>> {
|
||||||
|
let mut session = ImapExecutor::create_connection(account_id).await?;
|
||||||
|
let names = ImapExecutor::list_all_mailboxes(&mut session).await?;
|
||||||
|
let total = names.len();
|
||||||
|
mailbox_cache::update_fetch_progress(account_id, 0, total).await;
|
||||||
|
|
||||||
|
let mut mailboxes = Vec::new();
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
let mailbox_name = name.name().to_string();
|
||||||
|
let mut mailbox: MailBox = name.into();
|
||||||
|
|
||||||
|
if contains_no_select(&mailbox.attributes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mailbox.account_id = account_id;
|
||||||
|
mailbox.id = create_hash(account_id, &mailbox.name);
|
||||||
|
let mx = session
|
||||||
|
.examine(mailbox_name.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||||
|
mailbox.exists = mx.exists;
|
||||||
|
mailbox.unseen = mx.unseen;
|
||||||
|
mailbox.uid_next = mx.uid_next;
|
||||||
|
mailbox.uid_validity = mx.uid_validity;
|
||||||
|
|
||||||
|
mailboxes.push(mailbox);
|
||||||
|
mailbox_cache::update_fetch_progress(account_id, i + 1, total).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.logout().await.ok();
|
||||||
|
Ok(mailboxes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn request_imap_all_mailbox_list(account_id: u64) -> BichonResult<Vec<MailBox>> {
|
||||||
|
let mut session = ImapExecutor::create_connection(account_id).await?;
|
||||||
|
let names = ImapExecutor::list_all_mailboxes(&mut session).await?;
|
||||||
|
let result = convert_names_to_mailboxes(account_id, &mut session, names.iter()).await?;
|
||||||
|
session.logout().await.ok();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_no_select(attributes: &[Attribute]) -> bool {
|
||||||
|
attributes
|
||||||
|
.iter()
|
||||||
|
.any(|attr| attr.attr == AttributeEnum::NoSelect)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn convert_names_to_mailboxes(
|
||||||
|
account_id: u64,
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
names: impl IntoIterator<Item = &Name>,
|
||||||
|
) -> BichonResult<Vec<MailBox>> {
|
||||||
|
let mut mailboxes = Vec::new();
|
||||||
|
|
||||||
|
for name in names {
|
||||||
|
let mailbox_name = name.name().to_string();
|
||||||
|
let mut mailbox: MailBox = name.into();
|
||||||
|
|
||||||
|
if contains_no_select(&mailbox.attributes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mailbox.account_id = account_id;
|
||||||
|
mailbox.id = create_hash(account_id, &mailbox.name);
|
||||||
|
let mx = session
|
||||||
|
.examine(mailbox_name.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||||
|
mailbox.exists = mx.exists;
|
||||||
|
mailbox.unseen = mx.unseen;
|
||||||
|
mailbox.uid_next = mx.uid_next;
|
||||||
|
mailbox.uid_validity = mx.uid_validity;
|
||||||
|
|
||||||
|
mailboxes.push(mailbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(mailboxes)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
85
crates/core/src/message/append.rs
Normal file
85
crates/core/src/message/append.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use crate::{
|
||||||
|
encode_mailbox_name, raise_error,
|
||||||
|
{
|
||||||
|
account::migration::{AccountModel, AccountType},
|
||||||
|
envelope::extractor::reattach_eml_content,
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
imap::executor::ImapExecutor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
//use poem_openapi::Object;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const MAX_RESTORE_COUNT: usize = 100;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct RestoreMessagesRequest {
|
||||||
|
/// envelope IDs to restore (max 100)
|
||||||
|
pub envelope_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore_emails(account_id: u64, envelope_ids: Vec<String>) -> BichonResult<()> {
|
||||||
|
if envelope_ids.len() > MAX_RESTORE_COUNT {
|
||||||
|
return Err(raise_error!(
|
||||||
|
format!(
|
||||||
|
"Too many messages to restore: {} (max {})",
|
||||||
|
envelope_ids.len(),
|
||||||
|
MAX_RESTORE_COUNT
|
||||||
|
),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let account = AccountModel::check_account_exists(account_id)?;
|
||||||
|
if !matches!(account.account_type, AccountType::IMAP) {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Account type is not IMAP".into(),
|
||||||
|
ErrorCode::Incompatible
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut failed = Vec::new();
|
||||||
|
let mut session = ImapExecutor::create_connection(account_id).await?;
|
||||||
|
for envelope_id in envelope_ids {
|
||||||
|
let result: BichonResult<()> = async {
|
||||||
|
let (envelope, eml) = reattach_eml_content(account_id, envelope_id.clone())?;
|
||||||
|
if let Some(mailbox_name) = envelope.mailbox_name {
|
||||||
|
ImapExecutor::append(
|
||||||
|
&mut session,
|
||||||
|
encode_mailbox_name!(&mailbox_name),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&eml,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
tracing::warn!(
|
||||||
|
account_id = account_id,
|
||||||
|
message_id = &envelope_id,
|
||||||
|
error = ?err,
|
||||||
|
"Failed to restore email"
|
||||||
|
);
|
||||||
|
failed.push(envelope_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !failed.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
account_id = account_id,
|
||||||
|
failed_count = failed.len(),
|
||||||
|
failed_message_ids = ?failed,
|
||||||
|
"Restore emails finished with partial failures"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.logout().await.ok();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
105
crates/core/src/message/attachment.rs
Normal file
105
crates/core/src/message/attachment.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
raise_error,
|
||||||
|
{
|
||||||
|
dashboard::Group,
|
||||||
|
envelope::extractor::reattach_eml_content,
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
utils::compute_content_hash,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use mail_parser::MessageParser;
|
||||||
|
//use poem_openapi::Object;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct AttachmentMetadata {
|
||||||
|
/// Statistics of attachment file extensions (key + count).
|
||||||
|
/// Each item represents a file extension and its occurrence count.
|
||||||
|
/// Example: [{ key: "pdf", count: 10 }, { key: "png", count: 5 }]
|
||||||
|
pub extensions: Vec<Group>,
|
||||||
|
|
||||||
|
/// Statistics of attachment categories (key + count).
|
||||||
|
/// Each item represents a high-level category and its occurrence count.
|
||||||
|
/// Example: [{ key: "document", count: 8 }, { key: "image", count: 6 }]
|
||||||
|
pub categories: Vec<Group>,
|
||||||
|
|
||||||
|
/// Statistics of attachment MIME types (Content-Type) (key + count).
|
||||||
|
/// Each item represents a MIME type and its occurrence count.
|
||||||
|
/// Example: [{ key: "application/pdf", count: 10 }, { key: "image/jpeg", count: 5 }]
|
||||||
|
pub content_types: Vec<Group>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retrieve_attachment_content(
|
||||||
|
account_id: u64,
|
||||||
|
envelope_id: String,
|
||||||
|
content_hash: &str,
|
||||||
|
) -> BichonResult<Cursor<Bytes>> {
|
||||||
|
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
|
||||||
|
let message = MessageParser::default()
|
||||||
|
.parse(&eml)
|
||||||
|
.ok_or_else(|| raise_error!("Failed to parse EML".into(), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
let attachment_content: &[u8] = message
|
||||||
|
.attachments()
|
||||||
|
.find(|att| compute_content_hash(att.contents()) == content_hash)
|
||||||
|
.map(|att| att.contents())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Target attachment not found".into(),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Cursor::new(Bytes::copy_from_slice(attachment_content)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retrieve_nested_attachment_content(
|
||||||
|
account_id: u64,
|
||||||
|
envelope_id: String,
|
||||||
|
content_hash: &str,
|
||||||
|
nested_content_hash: &str,
|
||||||
|
) -> BichonResult<Cursor<Bytes>> {
|
||||||
|
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
|
||||||
|
let parent_message = MessageParser::default().parse(&eml).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Failed to parse parent EML".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let attachment_content = parent_message
|
||||||
|
.attachments()
|
||||||
|
.find(|att| compute_content_hash(att.contents()) == content_hash)
|
||||||
|
.map(|att| att.contents())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Target nested EML not found".into(),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let nested_message = MessageParser::default()
|
||||||
|
.parse(attachment_content)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Failed to parse nested EML".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let attachment_content = nested_message
|
||||||
|
.attachments()
|
||||||
|
.find(|att| compute_content_hash(att.contents()) == nested_content_hash)
|
||||||
|
.map(|att| att.contents())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Target nested EML not found".into(),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Cursor::new(Bytes::copy_from_slice(attachment_content)))
|
||||||
|
}
|
||||||
10
crates/core/src/message/contacts.rs
Normal file
10
crates/core/src/message/contacts.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//use poem_openapi::Object;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct Contact {
|
||||||
|
pub email: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
323
crates/core/src/message/content.rs
Normal file
323
crates/core/src/message/content.rs
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::account::migration::AccountModel;
|
||||||
|
use crate::base64_encode;
|
||||||
|
use crate::envelope::extractor::{extract_envelope_from_nested_message, reattach_eml_content};
|
||||||
|
use crate::error::code::ErrorCode;
|
||||||
|
use crate::store::envelope::Envelope;
|
||||||
|
use crate::utils::compute_content_hash;
|
||||||
|
use crate::{error::BichonResult, raise_error};
|
||||||
|
use mail_parser::{MessageParser, MimeHeaders};
|
||||||
|
//use poem_openapi::Object;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
/// Represents metadata of an attachment in a Gmail message.
|
||||||
|
///
|
||||||
|
/// This struct stores information required to identify, download,
|
||||||
|
/// and render an attachment, including inline images embedded
|
||||||
|
/// in HTML emails.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct AttachmentInfo {
|
||||||
|
/// MIME content type of the attachment (e.g., `image/png`, `application/pdf`).
|
||||||
|
pub file_type: String,
|
||||||
|
/// Whether the attachment is marked as inline (true) or a regular file (false).
|
||||||
|
pub inline: bool,
|
||||||
|
/// Original filename of the attachment, if provided.
|
||||||
|
pub filename: Option<String>,
|
||||||
|
/// Size of the attachment in bytes.
|
||||||
|
pub size: usize,
|
||||||
|
pub content_id: Option<String>,
|
||||||
|
/// Hash of the content.
|
||||||
|
pub content_hash: String,
|
||||||
|
pub is_message: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AttachmentInfo {
|
||||||
|
pub fn is_inline(&self) -> bool {
|
||||||
|
self.inline && self.content_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_extension(&self) -> Option<String> {
|
||||||
|
self.filename
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|f| std::path::Path::new(f).extension())
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_category(&self) -> &'static str {
|
||||||
|
if let Some(ext) = self.get_extension() {
|
||||||
|
let category = match ext.as_str() {
|
||||||
|
"doc" | "docx" | "pdf" | "rtf" | "odt" | "pages" | "pptx" | "ppt" => {
|
||||||
|
Some("document")
|
||||||
|
}
|
||||||
|
"xls" | "xlsx" | "ods" | "numbers" | "csv" => Some("spreadsheet"),
|
||||||
|
"ical" | "ics" | "vcs" | "ifb" | "icalendar" => Some("event"),
|
||||||
|
"txt" | "log" | "md" => Some("text"),
|
||||||
|
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "tiff" | "avif" | "heic" | "heif"
|
||||||
|
| "webp" => Some("image"),
|
||||||
|
"mp4" | "mkv" | "mov" | "avi" | "webm" => Some("video"),
|
||||||
|
"wav" | "mp3" | "aac" | "ogg" | "wma" | "flac" | "aiff" => Some("audio"),
|
||||||
|
"psd" | "eps" | "svg" | "cdr" | "ai" => Some("graphics_2d"),
|
||||||
|
"stl" | "obj" | "3mf" | "amf" | "f3d" | "sldprt" | "stp" | "step" | "dwg"
|
||||||
|
| "x_t" | "x_b" | "sat" | "ipt" => Some("graphics_3d"),
|
||||||
|
"c" | "h" | "html" | "css" | "js" | "ts" | "vue" | "tsx" | "svelte" | "py"
|
||||||
|
| "java" | "cs" | "go" | "rb" | "php" | "swift" | "rs" | "r" | "jl" | "lua"
|
||||||
|
| "sql" => Some("code"),
|
||||||
|
"tsv" | "xml" | "json" | "yml" | "yaml" | "toml" | "env" | "ini" => Some("data"),
|
||||||
|
"ps1" | "sh" | "bat" | "cmd" | "exe" | "msi" | "dmg" | "pkg" | "deb" | "rpm" => {
|
||||||
|
Some("executable")
|
||||||
|
}
|
||||||
|
"zip" | "gz" | "tgz" | "7z" | "rar" | "tar" | "bz2" | "zst" | "xz" | "iso"
|
||||||
|
| "img" => Some("archive"),
|
||||||
|
"eml" | "msg" => Some("message"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(cat) = category {
|
||||||
|
return cat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime = self.file_type.to_lowercase();
|
||||||
|
if mime.starts_with("image/") {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if mime.starts_with("video/") {
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
if mime.starts_with("audio/") {
|
||||||
|
return "audio";
|
||||||
|
}
|
||||||
|
if mime.starts_with("text/") {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
if mime == "message/rfc822" {
|
||||||
|
return "message";
|
||||||
|
}
|
||||||
|
if mime.contains("compressed") || mime.contains("zip") || mime.contains("archive") {
|
||||||
|
return "archive";
|
||||||
|
}
|
||||||
|
if mime.contains("pdf") || mime.contains("msword") || mime.contains("officedocument") {
|
||||||
|
return "document";
|
||||||
|
}
|
||||||
|
if mime.contains("spreadsheet") || mime.contains("excel") {
|
||||||
|
return "spreadsheet";
|
||||||
|
}
|
||||||
|
|
||||||
|
"other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Represents the content of an email message in both plain text and HTML formats.
|
||||||
|
///
|
||||||
|
/// This struct contains optional fields for plain text and HTML versions of
|
||||||
|
/// the email message body. At least one of them may be present.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// - `plain`: The plain text version of the message, if available.
|
||||||
|
/// - `html`: The HTML version of the message, if available.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct FullMessageContent {
|
||||||
|
/// Optional plain text version of the message.
|
||||||
|
pub text: Option<String>,
|
||||||
|
/// Optional HTML version of the message.
|
||||||
|
pub html: Option<String>,
|
||||||
|
// all Attachments include inline attachments
|
||||||
|
pub attachments: Option<Vec<AttachmentInfo>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct FullNestedMessageContent {
|
||||||
|
/// Optional plain text version of the message.
|
||||||
|
pub text: Option<String>,
|
||||||
|
/// Optional HTML version of the message.
|
||||||
|
pub html: Option<String>,
|
||||||
|
// all Attachments include inline attachments
|
||||||
|
pub attachments: Option<Vec<AttachmentInfo>>,
|
||||||
|
/// Metadata for the email envelope.
|
||||||
|
pub envelope: Envelope,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retrieve_email_content(
|
||||||
|
account_id: u64,
|
||||||
|
envelope_id: String,
|
||||||
|
) -> BichonResult<FullMessageContent> {
|
||||||
|
AccountModel::check_account_exists(account_id)?;
|
||||||
|
let (envelope, eml) = reattach_eml_content(account_id, envelope_id)?;
|
||||||
|
let message = MessageParser::default().parse(&eml).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Failed to parse EML data — the message may be corrupted.".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut html: Option<String> = message.body_html(0).map(|cow| cow.into_owned());
|
||||||
|
let text: Option<String> = message.body_text(0).map(|cow| cow.into_owned());
|
||||||
|
let mut attachments = Vec::new();
|
||||||
|
for attachment in message.attachments() {
|
||||||
|
let content_type = attachment.content_type().ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!(
|
||||||
|
"Attachment is missing Content-Type (email id={})",
|
||||||
|
&envelope.id
|
||||||
|
),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let filename = attachment.attachment_name().map(|name| name.to_string());
|
||||||
|
let disposition = attachment.content_disposition();
|
||||||
|
let file_type = format!(
|
||||||
|
"{}/{}",
|
||||||
|
content_type.c_type.as_ref(),
|
||||||
|
content_type.c_subtype.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
|
||||||
|
let inline = disposition.map(|d| d.is_inline()).unwrap_or(false);
|
||||||
|
|
||||||
|
if inline {
|
||||||
|
if let Some(html1) = html.as_deref() {
|
||||||
|
if let Some(cid) = attachment.content_id() {
|
||||||
|
if html1.contains(cid) {
|
||||||
|
let data = attachment.contents();
|
||||||
|
let base64_encoded = base64_encode!(data);
|
||||||
|
let html_content = html1.replace(
|
||||||
|
&format!("cid:{}", cid),
|
||||||
|
&format!("data:{};base64,{}", file_type, base64_encoded),
|
||||||
|
);
|
||||||
|
html = Some(html_content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//inline attachment will not be displayed in email attachment list
|
||||||
|
if inline && attachment.content_id().is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_message = attachment.is_message();
|
||||||
|
let content_hash = compute_content_hash(attachment.contents());
|
||||||
|
attachments.push(AttachmentInfo {
|
||||||
|
filename: filename.or(Some(content_hash.clone())), // Fallback to content_hash as the default filename if it is not provided.
|
||||||
|
size: attachment.contents().len(),
|
||||||
|
inline,
|
||||||
|
file_type,
|
||||||
|
is_message,
|
||||||
|
content_hash,
|
||||||
|
content_id: attachment.content_id().map(Into::into),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(FullMessageContent {
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
attachments: Some(attachments),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retrieve_nested_eml_content(
|
||||||
|
account_id: u64,
|
||||||
|
envelope_id: String,
|
||||||
|
content_hash: &str,
|
||||||
|
) -> BichonResult<FullNestedMessageContent> {
|
||||||
|
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
|
||||||
|
let parent_message = MessageParser::default().parse(&eml).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Failed to parse parent EML".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let attachment_content = parent_message
|
||||||
|
.attachments()
|
||||||
|
.find(|att| compute_content_hash(att.contents()) == content_hash)
|
||||||
|
.map(|att| att.contents())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Target nested EML not found".into(),
|
||||||
|
ErrorCode::ResourceNotFound
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let nested_message = MessageParser::default()
|
||||||
|
.parse(attachment_content)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"Failed to parse nested EML".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut html = nested_message.body_html(0).map(|c| c.into_owned());
|
||||||
|
let text = nested_message.body_text(0).map(|c| c.into_owned());
|
||||||
|
|
||||||
|
let mut attachments = Vec::new();
|
||||||
|
|
||||||
|
let has_html = html.is_some();
|
||||||
|
|
||||||
|
for attachment in nested_message.attachments() {
|
||||||
|
let cid = attachment.content_id();
|
||||||
|
let disposition = attachment.content_disposition();
|
||||||
|
let is_inline = disposition.map(|d| d.is_inline()).unwrap_or(false);
|
||||||
|
|
||||||
|
if has_html && is_inline && cid.is_some() {
|
||||||
|
let content_id = cid.unwrap();
|
||||||
|
let html_ref = html.as_mut().unwrap();
|
||||||
|
|
||||||
|
let cid_pattern = format!("cid:{}", content_id);
|
||||||
|
if html_ref.contains(&cid_pattern) {
|
||||||
|
let data = attachment.contents();
|
||||||
|
let ct = attachment
|
||||||
|
.content_type()
|
||||||
|
.map(|ct| format!("{}/{}", ct.c_type, ct.c_subtype.as_deref().unwrap_or("")))
|
||||||
|
.unwrap_or_else(|| "image/png".to_string());
|
||||||
|
|
||||||
|
let base64_data = format!("data:{};base64,{}", ct, base64_encode!(data));
|
||||||
|
*html_ref = html_ref.replace(&cid_pattern, &base64_data);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_type = attachment.content_type().map_or_else(
|
||||||
|
|| "application/octet-stream".to_string(),
|
||||||
|
|ct| format!("{}/{}", ct.c_type, ct.c_subtype.as_deref().unwrap_or("")),
|
||||||
|
);
|
||||||
|
let content_hash = compute_content_hash(attachment.contents());
|
||||||
|
attachments.push(AttachmentInfo {
|
||||||
|
filename: attachment
|
||||||
|
.attachment_name()
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.or(Some(content_hash.clone())), // Fallback to content_hash as the default filename if it is not provided.
|
||||||
|
size: attachment.contents().len(),
|
||||||
|
inline: is_inline,
|
||||||
|
file_type,
|
||||||
|
content_hash,
|
||||||
|
is_message: attachment.is_message(),
|
||||||
|
content_id: cid.map(Into::into),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let envelope = extract_envelope_from_nested_message(nested_message, account_id)?;
|
||||||
|
|
||||||
|
Ok(FullNestedMessageContent {
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
attachments: Some(attachments),
|
||||||
|
envelope,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -16,16 +16,16 @@
|
|||||||
// 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::error::BichonResult;
|
||||||
use crate::modules::error::BichonResult;
|
use crate::store::tantivy::attachment::ATTACHMENT_MANAGER;
|
||||||
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
|
use crate::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub async fn delete_messages_impl(request: HashMap<u64, Vec<u64>>) -> BichonResult<()> {
|
pub async fn delete_messages_impl(request: HashMap<u64, Vec<String>>) -> BichonResult<()> {
|
||||||
EML_INDEX_MANAGER
|
ENVELOPE_MANAGER
|
||||||
.delete_email_multi_account(&request)
|
.delete_envelopes_multi_account(request.clone())
|
||||||
.await?;
|
.await?;
|
||||||
ENVELOPE_INDEX_MANAGER
|
ATTACHMENT_MANAGER
|
||||||
.delete_envelopes_multi_account(&request)
|
.delete_envelopes_multi_account(request)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
34
crates/core/src/message/list.rs
Normal file
34
crates/core/src/message/list.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
account::migration::AccountModel,
|
||||||
|
common::paginated::DataPage,
|
||||||
|
error::BichonResult,
|
||||||
|
store::{envelope::Envelope, tantivy::envelope::ENVELOPE_MANAGER},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn get_thread_messages(
|
||||||
|
account_id: u64,
|
||||||
|
thread_id: &str,
|
||||||
|
page: u64,
|
||||||
|
page_size: u64,
|
||||||
|
) -> BichonResult<DataPage<Envelope>> {
|
||||||
|
AccountModel::check_account_exists(account_id)?;
|
||||||
|
ENVELOPE_MANAGER.list_thread_envelopes(account_id, thread_id, page, page_size, true)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
//
|
//
|
||||||
// This file is part of the Bichon Email Archiving Project
|
// This file is part of the Bichon Email Archiving Project
|
||||||
//
|
//
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
// 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 append;
|
pub mod append;
|
||||||
|
pub mod attachment;
|
||||||
|
pub mod contacts;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
pub mod delete;
|
pub mod delete;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
183
crates/core/src/message/search.rs
Normal file
183
crates/core/src/message/search.rs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||||
|
//
|
||||||
|
// This file is part of the Bichon Email Archiving Project
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//use poem_openapi::{Enum, Object};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
common::paginated::DataPage,
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
raise_error,
|
||||||
|
store::{
|
||||||
|
envelope::Envelope,
|
||||||
|
tantivy::{
|
||||||
|
attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER, model::AttachmentModel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct EmailSearchFilter {
|
||||||
|
pub text: Option<String>,
|
||||||
|
pub subject: Option<String>,
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub to: Option<String>,
|
||||||
|
pub cc: Option<String>,
|
||||||
|
pub bcc: Option<String>,
|
||||||
|
pub since: Option<i64>,
|
||||||
|
pub before: Option<i64>,
|
||||||
|
pub account_ids: Option<HashSet<u64>>,
|
||||||
|
pub mailbox_ids: Option<HashSet<u64>>,
|
||||||
|
pub min_size: Option<u64>,
|
||||||
|
pub max_size: Option<u64>,
|
||||||
|
pub message_id: Option<String>,
|
||||||
|
pub has_attachment: Option<bool>,
|
||||||
|
pub attachment_name: Option<String>,
|
||||||
|
pub tags: Option<HashSet<String>>,
|
||||||
|
pub attachment_extension: Option<String>,
|
||||||
|
pub attachment_category: Option<String>,
|
||||||
|
pub attachment_content_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||||
|
pub enum SortBy {
|
||||||
|
#[default]
|
||||||
|
DATE,
|
||||||
|
SIZE,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct EmailSearchRequest {
|
||||||
|
pub filter: EmailSearchFilter,
|
||||||
|
pub page: u64,
|
||||||
|
pub page_size: u64,
|
||||||
|
pub sort_by: Option<SortBy>,
|
||||||
|
pub desc: Option<bool>,
|
||||||
|
}
|
||||||
|
impl EmailSearchRequest {
|
||||||
|
pub fn validate(&self) -> BichonResult<()> {
|
||||||
|
if self.page == 0 || self.page_size == 0 {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Both page and page_size must be greater than 0.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.page_size > 500 {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"The page_size exceeds the maximum allowed limit of 500.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_messages_impl(
|
||||||
|
accounts: Option<HashSet<u64>>,
|
||||||
|
request: EmailSearchRequest,
|
||||||
|
) -> BichonResult<DataPage<Envelope>> {
|
||||||
|
request.validate()?;
|
||||||
|
ENVELOPE_MANAGER.search(
|
||||||
|
accounts,
|
||||||
|
request.filter,
|
||||||
|
request.page,
|
||||||
|
request.page_size,
|
||||||
|
request.desc.unwrap_or(true),
|
||||||
|
request.sort_by.unwrap_or(SortBy::DATE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct AttachmentSearchFilter {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub text: Option<String>,
|
||||||
|
pub subject: Option<String>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub since: Option<i64>,
|
||||||
|
pub before: Option<i64>,
|
||||||
|
pub account_ids: Option<HashSet<u64>>,
|
||||||
|
pub mailbox_ids: Option<HashSet<u64>>,
|
||||||
|
pub min_size: Option<u64>,
|
||||||
|
pub max_size: Option<u64>,
|
||||||
|
|
||||||
|
pub attachment_name: Option<String>,
|
||||||
|
pub content_hash: Option<String>,
|
||||||
|
|
||||||
|
pub tags: Option<HashSet<String>>,
|
||||||
|
pub attachment_extension: Option<String>,
|
||||||
|
pub attachment_category: Option<String>,
|
||||||
|
pub attachment_content_type: Option<String>,
|
||||||
|
|
||||||
|
pub is_ocr: Option<bool>,
|
||||||
|
pub is_message: Option<bool>,
|
||||||
|
pub has_text: Option<bool>,
|
||||||
|
|
||||||
|
pub min_page_count: Option<u64>,
|
||||||
|
pub max_page_count: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
|
pub struct AttachmentSearchRequest {
|
||||||
|
filter: AttachmentSearchFilter,
|
||||||
|
page: u64,
|
||||||
|
page_size: u64,
|
||||||
|
sort_by: Option<SortBy>,
|
||||||
|
desc: Option<bool>,
|
||||||
|
}
|
||||||
|
impl AttachmentSearchRequest {
|
||||||
|
pub fn validate(&self) -> BichonResult<()> {
|
||||||
|
if self.page == 0 || self.page_size == 0 {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Both page and page_size must be greater than 0.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.page_size > 500 {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"The page_size exceeds the maximum allowed limit of 500.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_attachment_impl(
|
||||||
|
accounts: Option<HashSet<u64>>,
|
||||||
|
request: AttachmentSearchRequest,
|
||||||
|
) -> BichonResult<DataPage<AttachmentModel>> {
|
||||||
|
request.validate()?;
|
||||||
|
ATTACHMENT_MANAGER.search(
|
||||||
|
accounts,
|
||||||
|
request.filter,
|
||||||
|
request.page,
|
||||||
|
request.page_size,
|
||||||
|
request.desc.unwrap_or(true),
|
||||||
|
request.sort_by.unwrap_or(SortBy::DATE),
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user