mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f82b20e2fd | ||
|
|
9841038acb | ||
|
|
97c3db3bd2 | ||
|
|
82fb2a02bc | ||
|
|
c61977ce5c | ||
|
|
106a08fb7e | ||
|
|
3419506c2e | ||
|
|
3b040d0cd6 | ||
|
|
5d3c319a67 | ||
|
|
ad2a43aa35 | ||
|
|
884ae64fc5 | ||
|
|
254de35f0e | ||
|
|
bc3eba5bf7 | ||
|
|
4dc99b4a84 | ||
|
|
8ce8b9692d | ||
|
|
3fb761064d | ||
|
|
54a0a71c44 | ||
|
|
b490923e17 | ||
|
|
4ee44daf0d | ||
|
|
7edd7c2e35 | ||
|
|
0c46432150 | ||
|
|
d334a23ca7 | ||
|
|
1768c1a590 | ||
|
|
147f5b4f55 | ||
|
|
48312dc83e | ||
|
|
55e97510c4 | ||
|
|
0bf2003670 | ||
|
|
e56fe5ebea | ||
|
|
c69ada32ef | ||
|
|
3f4b37be17 | ||
|
|
09375ee11c | ||
|
|
b2e43b0907 | ||
|
|
44fc0e15de | ||
|
|
ef891b20c3 | ||
|
|
a6216c2ce6 | ||
|
|
1c58b516dd | ||
|
|
ae916574de | ||
|
|
14fb3368a3 | ||
|
|
f49929dd67 | ||
|
|
97143d55b8 | ||
|
|
e666f76d87 | ||
|
|
7fb6575f8d | ||
|
|
fb0be8c5d1 | ||
|
|
455e6b1a75 | ||
|
|
75cae51be9 | ||
|
|
62d956c7d6 | ||
|
|
c01872284e | ||
|
|
2887b5d16d | ||
|
|
558ea2f9b0 | ||
|
|
b07defa2d5 | ||
|
|
76ab16b55b | ||
|
|
06a126461b | ||
|
|
a02bb65ca0 | ||
|
|
1f57f372d3 | ||
|
|
b35493e4e1 | ||
|
|
16578fb8e2 | ||
|
|
6dd3f90ee0 | ||
|
|
6d11dcd33f | ||
|
|
e64c2467fd | ||
|
|
e8a15695d8 | ||
|
|
0f3ad83004 | ||
|
|
4af5176b65 | ||
|
|
1e2f526a07 | ||
|
|
97be76278e | ||
|
|
d4232789f9 | ||
|
|
9b83d5617e | ||
|
|
70db81dc03 | ||
|
|
6b18d7371d | ||
|
|
934e81c5f9 | ||
|
|
c05a8944ef | ||
|
|
eaff2ca70d | ||
|
|
000d144e70 | ||
|
|
20970b4fb6 | ||
|
|
57df3466e7 | ||
|
|
75d859abdf | ||
|
|
b0412b02f5 |
@@ -8,6 +8,12 @@ assignees: ""
|
||||
|
||||
> **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
|
||||
Which version are you using?
|
||||
|
||||
@@ -8,6 +8,13 @@ assignees: ""
|
||||
|
||||
> **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
|
||||
What feature would you like to see?
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ on:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
env:
|
||||
BINARY_NAME: bichon
|
||||
BINARY_NAME: bichon-server
|
||||
BINARY_CLI: bichon-cli
|
||||
BINARY_ADMIN: bichon-admin
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,6 +36,22 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify Cargo.toml version matches git tag
|
||||
shell: bash
|
||||
run: |
|
||||
TAG_VERSION="${GITHUB_REF_NAME}"
|
||||
|
||||
CARGO_VERSION=$(grep '^version' Cargo.toml | head -n1 | cut -d '"' -f2)
|
||||
|
||||
echo "Git tag version: $TAG_VERSION"
|
||||
echo "Cargo.toml version: $CARGO_VERSION"
|
||||
|
||||
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
|
||||
echo "::error::Version mismatch! Git tag ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
@@ -64,17 +82,19 @@ jobs:
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
cargo install cross --force
|
||||
cross build --release --features vendored-openssl --target=${{ matrix.target }}
|
||||
cross build --release --target=${{ matrix.target }}
|
||||
|
||||
- name: Build Rust backend
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
cargo build --release --features vendored-openssl --target=${{ matrix.target }}
|
||||
cargo build --release --target=${{ matrix.target }}
|
||||
|
||||
- name: Strip binary (Linux and macOS)
|
||||
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_CLI }}
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}
|
||||
|
||||
- name: Pack artifact (Linux/macOS)
|
||||
if: matrix.os != 'windows-latest'
|
||||
@@ -83,7 +103,9 @@ jobs:
|
||||
mkdir -p release
|
||||
BINARY="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
|
||||
cp README.md LICENSE release/
|
||||
cp "$BINARY" release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} 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 .
|
||||
mv "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" release/
|
||||
|
||||
@@ -99,10 +121,14 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
mkdir -p release
|
||||
$BINARY = "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe"
|
||||
Copy-Item -Path $BINARY -Destination release/
|
||||
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" release/
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CLI }}.exe" release/
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}.exe" release/
|
||||
|
||||
Copy-Item -Path README.md -Destination release/
|
||||
Copy-Item -Path LICENSE -Destination release/
|
||||
|
||||
Compress-Archive -Path release\* -DestinationPath "release/${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.zip" -Force
|
||||
|
||||
- name: Upload build artifact
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/target
|
||||
.vscode
|
||||
.idea
|
||||
config.toml
|
||||
Generated
+1929
-1774
File diff suppressed because it is too large
Load Diff
+59
-76
@@ -1,49 +1,34 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.1.4"
|
||||
[workspace]
|
||||
|
||||
members = [
|
||||
"crates/memdb",
|
||||
"crates/core",
|
||||
"crates/server",
|
||||
"crates/cli",
|
||||
"crates/admin",
|
||||
]
|
||||
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.1"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "bichon"
|
||||
path = "src/main.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.53", features = ["derive", "env"] }
|
||||
mimalloc = "0.1.48"
|
||||
native_db = "0.8.2"
|
||||
[workspace.dependencies]
|
||||
chrono = "0.4.44"
|
||||
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||
mimalloc = "0.1.50"
|
||||
memdb = { path = "crates/memdb" }
|
||||
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"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tracing = "0.1.43"
|
||||
serde_json = "1.0.149"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
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"
|
||||
snafu = "0.8.9"
|
||||
snafu = "0.9.0"
|
||||
reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
@@ -54,61 +39,59 @@ reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
] }
|
||||
tokio-socks = "0.5.2"
|
||||
http = "1.4.0"
|
||||
regex = "1.12.2"
|
||||
regex = "1.12.3"
|
||||
email_address = "0.2.9"
|
||||
futures = "0.3.31"
|
||||
futures = "0.3.32"
|
||||
utf7-imap = "0.3.2"
|
||||
imap-proto = "0.16.6"
|
||||
mail-parser = { version = '0.11.1', features = ["serde"] }
|
||||
mail-parser = { version = '0.11.3', features = ["serde"] }
|
||||
# mail-send = "0.5.2"
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
"ring",
|
||||
"tls12",
|
||||
] }
|
||||
timeago = "0.5.0"
|
||||
ahash = "0.8.12"
|
||||
timeago = "0.6.0"
|
||||
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
|
||||
url = { version = "2.5.7", features = ["serde"] }
|
||||
sysinfo = "0.37.2"
|
||||
url = { version = "2.5.8", features = ["serde"] }
|
||||
sysinfo = "0.39.1"
|
||||
num_cpus = "1.17.0"
|
||||
cacache = { version = "13.1.0", default-features = false, features = [
|
||||
"tokio-runtime",
|
||||
"mmap",
|
||||
] }
|
||||
rand = "0.9.2"
|
||||
rand = "0.10.1"
|
||||
encoding_rs = "0.8.35"
|
||||
async-imap = { version = "0.11.1", default-features = false, features = [
|
||||
"runtime-tokio",
|
||||
"compress",
|
||||
] }
|
||||
webpki-roots = "1.0.4"
|
||||
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
|
||||
rustls-pki-types = "1.13.1"
|
||||
webpki-roots = "1.0.7"
|
||||
rustls = { version = "0.23.40", default-features = false, features = ["ring"] }
|
||||
rustls-pki-types = "1.14.1"
|
||||
tokio-io-timeout = "1.2.1"
|
||||
bb8 = "0.9.1"
|
||||
semver = "1.0.27"
|
||||
governor = "0.10.2"
|
||||
lru = "0.16.2"
|
||||
semver = "1.0.28"
|
||||
governor = "0.10.4"
|
||||
lru = "0.18.0"
|
||||
mime_guess = "2.0.5"
|
||||
hex = "0.4.3"
|
||||
time = { version = "0.3.44", features = [
|
||||
time = { version = "0.3.47", features = [
|
||||
"formatting",
|
||||
"parsing",
|
||||
"local-offset",
|
||||
] }
|
||||
rust-embed = "8.9.0"
|
||||
rust-embed = "8.11.0"
|
||||
murmur3 = "0.5.2"
|
||||
autoconfig = "0.4.0"
|
||||
urlencoding = "2.1.3"
|
||||
dashmap = "6.1.0"
|
||||
# Statically links OpenSSL by compiling from source, avoiding system library dependencies
|
||||
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
|
||||
gethostname = "1.1.0"
|
||||
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
|
||||
itoa = "1.0.15"
|
||||
html2text = "0.16.4"
|
||||
bytes = "1.11.0"
|
||||
[dev-dependencies]
|
||||
#bincode = "1.3.3"
|
||||
#secret-lib = "1.0.0"
|
||||
tempfile = "3.23.0"
|
||||
itoa = "1.0.18"
|
||||
html2text = "0.17.1"
|
||||
bytes = "1.11.1"
|
||||
dialoguer = "0.12.0"
|
||||
console = "0.16.3"
|
||||
mail-send = "0.6.0"
|
||||
rcgen = "0.14.8"
|
||||
rustls-pemfile = "2.2.0"
|
||||
blake3 = "1.8.5"
|
||||
uuid = { version = "1.23.1", features = ["v4", "serde"] }
|
||||
fjall = { version = "3.1.4", features = ["lz4", "metrics", "bytes_1"] }
|
||||
tracing-log = "0.2.0"
|
||||
tokio-util = "0.7.18"
|
||||
indicatif = "0.18.4"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
opt-level = 3
|
||||
codegen-units = 1
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
base_url = "http://localhost:15630"
|
||||
api_token = "WuqNC0g8yNle7CVnxcvjUwjN"
|
||||
@@ -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"
|
||||
@@ -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.0",
|
||||
"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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.0")
|
||||
.bold()
|
||||
.yellow()
|
||||
);
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style(
|
||||
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
|
||||
architecture to the new v1.0.0 \
|
||||
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.0 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.x Tantivy-based storage detected. Migration to v1.0 is required.")
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
Ok(false) => {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("✔").green(),
|
||||
style("No legacy v0.x 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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod download;
|
||||
pub mod search;
|
||||
pub mod sender;
|
||||
pub mod stats;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//
|
||||
// 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 console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Select};
|
||||
use reqwest::Client;
|
||||
|
||||
use bichon_core::{
|
||||
account::payload::MinimalAccount,
|
||||
users::{permissions::Permission, view::UserView},
|
||||
};
|
||||
|
||||
use crate::BichonCliConfig as BichonCliConfig;
|
||||
|
||||
pub async fn verify_user_and_get_account(
|
||||
config: &BichonCliConfig,
|
||||
theme: &ColorfulTheme,
|
||||
only_nosync: bool,
|
||||
) -> MinimalAccount {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/v1/current-user", config.base_url);
|
||||
|
||||
let response = match client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"\n{} {}",
|
||||
style("✘ Network Error:").red().bold(),
|
||||
"Could not connect to Bichon service."
|
||||
);
|
||||
eprintln!("{} {}", style("Details:").dim(), e);
|
||||
eprintln!(
|
||||
"\n{} Please check if the Base URL is correct and the server is running.",
|
||||
style("Tip:").cyan()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "No error detail provided".to_string());
|
||||
|
||||
eprintln!(
|
||||
"\n{} Server returned an error (Status: {})",
|
||||
style("✘ API Error:").red().bold(),
|
||||
style(status).yellow()
|
||||
);
|
||||
|
||||
if status == 401 {
|
||||
eprintln!(
|
||||
"{} Your API Token seems to be invalid or expired.",
|
||||
style("Context:").dim()
|
||||
);
|
||||
} else if status == 404 {
|
||||
eprintln!(
|
||||
"{} The endpoint was not found. Please check your Base URL.",
|
||||
style("Context:").dim()
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("{} {}", style("Response:").dim(), error_body);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let user: UserView = response.json().await.expect("Failed to parse user data");
|
||||
println!("Welcome, {}!", style(&user.username).cyan());
|
||||
|
||||
let account_list_url = format!(
|
||||
"{}/api/v1/minimal-account-list?only_nosync={only_nosync}",
|
||||
config.base_url
|
||||
);
|
||||
let acc_response = client
|
||||
.get(&account_list_url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to fetch account list");
|
||||
|
||||
if !acc_response.status().is_success() {
|
||||
panic!(
|
||||
"Failed to retrieve accounts. Status: {}",
|
||||
acc_response.status()
|
||||
);
|
||||
}
|
||||
|
||||
let accounts: Vec<MinimalAccount> = acc_response
|
||||
.json()
|
||||
.await
|
||||
.expect("Failed to parse minimal account list");
|
||||
|
||||
if accounts.is_empty() {
|
||||
println!(
|
||||
"\n{}",
|
||||
style("Error: No 'nosync' accounts found.").red().bold()
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
style("Mail import is only supported for 'nosync' type accounts.").dim()
|
||||
);
|
||||
println!(
|
||||
"Please create a new {} account in the Bichon web interface first.",
|
||||
style("Nosync").bold().yellow()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
let required_permission = Permission::DATA_IMPORT_BATCH;
|
||||
let mut selectable_accounts = Vec::new();
|
||||
let mut options = Vec::new();
|
||||
|
||||
for acc in accounts {
|
||||
let has_permission = if let Some(perms) = user.account_permissions.get(&acc.id) {
|
||||
perms.iter().any(|p| p == required_permission)
|
||||
} else {
|
||||
user.global_permissions
|
||||
.iter()
|
||||
.any(|p| p == Permission::DATA_MANAGE_ALL || p == Permission::ROOT)
|
||||
};
|
||||
|
||||
let status_prefix = if has_permission {
|
||||
style(" [READY] ").green()
|
||||
} else {
|
||||
style(" [NO PERMISSION] ").red()
|
||||
};
|
||||
|
||||
options.push(format!(
|
||||
"{}{} - {}",
|
||||
status_prefix,
|
||||
style(&acc.email).bold(),
|
||||
style(format!("ID: {}", acc.id)).dim()
|
||||
));
|
||||
|
||||
selectable_accounts.push((acc, has_permission));
|
||||
}
|
||||
|
||||
let selection = Select::with_theme(theme)
|
||||
.with_prompt("Select the target account for import")
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.max_length(10)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let (selected_acc, can_import) = &selectable_accounts[selection];
|
||||
|
||||
if !*can_import {
|
||||
eprintln!(
|
||||
"\n{} You do not have '{}' permission for account {}.",
|
||||
style("✘ Permission Denied:").red().bold(),
|
||||
style(required_permission).yellow(),
|
||||
style(&selected_acc.email).cyan()
|
||||
);
|
||||
eprintln!(
|
||||
"{} Please contact your administrator to upgrade your role for this account.",
|
||||
style("Tip:").dim()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} Targeting account: {}",
|
||||
style("✔").green(),
|
||||
style(&selected_acc.email).cyan().bold()
|
||||
);
|
||||
|
||||
selected_acc.clone()
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// 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,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use mail_parser::MessageParser;
|
||||
use reqwest::Client;
|
||||
|
||||
use bichon_core::base64_encode_url_safe;
|
||||
|
||||
use crate::{BichonCliConfig, api::sender::send_batch_request};
|
||||
|
||||
pub async fn handle_eml_directory_import(
|
||||
config: &BichonCliConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
let root_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the ROOT directory to scan for .eml files")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if p.exists() && p.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Directory not found.")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let root_path = std::path::PathBuf::from(root_str);
|
||||
let mut tasks: HashMap<String, Vec<PathBuf>> = HashMap::new();
|
||||
println!(
|
||||
"{}",
|
||||
style("🔍 Scanning recursively using std::fs...").dim()
|
||||
);
|
||||
if let Err(e) = scan_dir(&root_path, &root_path, &mut tasks) {
|
||||
eprintln!("Error scanning directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if tasks.is_empty() {
|
||||
println!("{}", style("No .eml files found.").yellow());
|
||||
} else {
|
||||
process_and_upload(config, account_id, tasks).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_dir(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
tasks: &mut HashMap<String, Vec<PathBuf>>,
|
||||
) -> std::io::Result<()> {
|
||||
if current.is_dir() {
|
||||
for entry in fs::read_dir(current)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
scan_dir(root, &path, tasks)?;
|
||||
} else if path.is_file() {
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("eml") {
|
||||
let rel_path = path.strip_prefix(root).unwrap_or(Path::new(""));
|
||||
let mailbox_name = rel_path
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
let folder = if mailbox_name.is_empty() {
|
||||
"Inbox".to_string()
|
||||
} else {
|
||||
mailbox_name
|
||||
};
|
||||
tasks.entry(folder).or_insert_with(|| Vec::new()).push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_and_upload(
|
||||
config: &BichonCliConfig,
|
||||
account_id: u64,
|
||||
tasks: HashMap<String, Vec<PathBuf>>,
|
||||
) {
|
||||
let client = Client::new();
|
||||
let batch_size = 50;
|
||||
|
||||
for (mailbox, files) in tasks {
|
||||
println!("\n🚀 Processing mailbox: {}", style(&mailbox).cyan().bold());
|
||||
|
||||
let mut current_batch = Vec::new();
|
||||
|
||||
for file_path in files {
|
||||
let body = match fs::read(&file_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {} Failed to read file {:?}: {}",
|
||||
style("✘").red(),
|
||||
file_path,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if MessageParser::new().parse(&body).is_some() {
|
||||
let b64_content = base64_encode_url_safe!(&body);
|
||||
current_batch.push(b64_content);
|
||||
|
||||
if current_batch.len() >= batch_size {
|
||||
let to_send = current_batch;
|
||||
current_batch = Vec::with_capacity(batch_size);
|
||||
send_batch_request(&client, config, account_id, &mailbox, to_send).await;
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
" {} Invalid format, skipping: {:?}",
|
||||
style("⚠").yellow(),
|
||||
file_path
|
||||
);
|
||||
}
|
||||
}
|
||||
if !current_batch.is_empty() {
|
||||
send_batch_request(&client, config, account_id, &mailbox, current_batch).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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!(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// 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;
|
||||
|
||||
pub fn determine_folder(labels_raw: &str) -> String {
|
||||
let mut status_blacklist = HashSet::new();
|
||||
status_blacklist.insert("Opened");
|
||||
status_blacklist.insert("Unread");
|
||||
status_blacklist.insert("Archived");
|
||||
|
||||
let all_labels: Vec<&str> = labels_raw
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if all_labels.is_empty() {
|
||||
return "Unknown".to_string();
|
||||
}
|
||||
|
||||
let filtered: Vec<&str> = all_labels
|
||||
.iter()
|
||||
.filter(|&&l| !status_blacklist.contains(l))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
match filtered.len() {
|
||||
// Case A: If all labels were status labels, fallback to the first original label
|
||||
0 => all_labels[0].to_string(),
|
||||
// Case B: If only one label remains, that's our target destination
|
||||
1 => filtered[0].to_string(),
|
||||
// Case C: Multiple labels remain (e.g., ["Inbox", "medium"])
|
||||
_ => {
|
||||
// Prioritize custom business labels by excluding generic locations like "Inbox" or "Sent"
|
||||
let business_label = filtered.iter().find(|&&l| l != "Inbox" && l != "Sent");
|
||||
|
||||
match business_label {
|
||||
// Return the first non-generic label found
|
||||
Some(label) => label.to_string(),
|
||||
// If only generic labels remain (e.g., ["Sent", "Inbox"]), pick the first available
|
||||
None => filtered[0].to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//
|
||||
// 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;
|
||||
|
||||
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 batch_limit = 50;
|
||||
|
||||
println!("Starting import process...");
|
||||
|
||||
for (index, e) in mbox.iter().enumerate() {
|
||||
let msg_num = index + 1;
|
||||
let body = e.data;
|
||||
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..."
|
||||
);
|
||||
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()
|
||||
};
|
||||
|
||||
let b64_eml = base64_encode_url_safe!(&body);
|
||||
let buffer = folder_buffers
|
||||
.entry(folder_name.clone())
|
||||
.or_insert_with(|| Vec::new());
|
||||
buffer.push(b64_eml);
|
||||
|
||||
if buffer.len() >= batch_limit {
|
||||
let emls_to_send = folder_buffers.remove(&folder_name).unwrap();
|
||||
send_batch_request(&client, config, account_id, &folder_name, emls_to_send).await;
|
||||
}
|
||||
}
|
||||
|
||||
for (folder_name, emls) in folder_buffers {
|
||||
if !emls.is_empty() {
|
||||
send_batch_request(&client, config, account_id, &folder_name, emls).await;
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", style("Import completed successfully!").green().bold());
|
||||
}
|
||||
@@ -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 memmap2::Mmap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct MboxFile {
|
||||
map: Mmap,
|
||||
}
|
||||
|
||||
impl MboxFile {
|
||||
pub fn from_file(name: &Path) -> io::Result<Self> {
|
||||
let file = fs::File::open(name)?;
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.len() == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Empty MBOX file",
|
||||
));
|
||||
}
|
||||
let map = unsafe { Mmap::map(&file)? };
|
||||
Ok(Self { map })
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> MboxReader<'_> {
|
||||
MboxReader::new(&self.map)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Entry<'a> {
|
||||
pub offset: usize,
|
||||
pub data: &'a [u8],
|
||||
}
|
||||
|
||||
pub struct MboxReader<'a> {
|
||||
data: &'a [u8],
|
||||
len: usize,
|
||||
scan_pos: usize,
|
||||
body_start: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> MboxReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
len: data.len(),
|
||||
scan_pos: 0,
|
||||
body_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_from_line(&self, i: usize) -> bool {
|
||||
if i + 5 > self.len {
|
||||
return false;
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
&self.data[0..5] == b"From "
|
||||
} else {
|
||||
self.data[i - 1] == b'\n' && &self.data[i..i + 5] == b"From "
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_from_line(&self, mut i: usize) -> usize {
|
||||
while i < self.len && self.data[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
if i < self.len {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for MboxReader<'a> {
|
||||
type Item = Entry<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
while self.scan_pos < self.len {
|
||||
if self.is_from_line(self.scan_pos) {
|
||||
let from_pos = self.scan_pos;
|
||||
let body_pos = self.skip_from_line(from_pos);
|
||||
|
||||
if let Some(start) = self.body_start {
|
||||
let entry = Entry {
|
||||
offset: start,
|
||||
data: &self.data[start..from_pos],
|
||||
};
|
||||
self.body_start = Some(body_pos);
|
||||
self.scan_pos = body_pos;
|
||||
return Some(entry);
|
||||
} else {
|
||||
self.body_start = Some(body_pos);
|
||||
self.scan_pos = body_pos;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
self.scan_pos += 1;
|
||||
}
|
||||
if let Some(start) = self.body_start.take() {
|
||||
return Some(Entry {
|
||||
offset: start,
|
||||
data: &self.data[start..self.len],
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mail_parser::MessageParser;
|
||||
|
||||
use crate::mbox::gmail::determine_folder;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn collect_entries(data: &[u8]) -> Vec<&[u8]> {
|
||||
let reader = MboxReader::new(data);
|
||||
reader.map(|e| e.data).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_mails() {
|
||||
let data = b"From a\nmail1\nFrom b\nmail2\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e, vec![b"mail1\n", b"mail2\n"]);
|
||||
}
|
||||
#[test]
|
||||
fn no_trailing_newline() {
|
||||
let data = b"From a\nmail1";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e, vec![b"mail1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_inside_body() {
|
||||
let data = b"From a\nhello\nFrom is here\nbye\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_from_not_separator() {
|
||||
let data = b"From a\nhello From world\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realistic_mbox() {
|
||||
let data = b"From a\nH:1\n\nbody1\nFrom b\nH:2\n\nbody2\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body() {
|
||||
let data = b"From a\nFrom b\nbody\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e[0], b"");
|
||||
assert_eq!(e[1], b"body\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_from_line() {
|
||||
let data = b"From a\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0], b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_newlines() {
|
||||
let data = b"From a\r\nbody\r\nFrom b\r\nbody2\r\n";
|
||||
let e = collect_entries(data);
|
||||
assert_eq!(e.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_small_mails() {
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..1000 {
|
||||
data.extend_from_slice(b"From a\nx\n");
|
||||
}
|
||||
let e = collect_entries(&data);
|
||||
assert_eq!(e.len(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test11() {
|
||||
let mbox = MboxFile::from_file(Path::new("e:\\test.mbox")).unwrap();
|
||||
|
||||
for e in mbox.iter() {
|
||||
let body = e.data;
|
||||
|
||||
let message = MessageParser::new().parse(body).unwrap();
|
||||
let labels = message.header("X-Gmail-Labels").unwrap().as_text().unwrap();
|
||||
//println!("offset={} X-Gmail-Labels={:?}", e.offset, labels);
|
||||
println!(
|
||||
"X-Gmail-Labels={:?}, determine_folder={}",
|
||||
labels,
|
||||
determine_folder(labels)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 compressed_rtf::*;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
pub fn decode_subject(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => {
|
||||
let offset = match value.buffer().first() {
|
||||
Some(1) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let buffer: Vec<_> = value
|
||||
.buffer()
|
||||
.iter()
|
||||
.skip(offset)
|
||||
.map(|&b| u16::from(b))
|
||||
.collect();
|
||||
Some(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
PropertyValue::Unicode(value) => {
|
||||
let offset = match value.buffer().first() {
|
||||
Some(1) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
Some(String::from_utf16_lossy(&value.buffer()[offset..]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option<String> {
|
||||
match code_page {
|
||||
20127 => {
|
||||
let buffer: Vec<_> = buffer.iter().map(|&b| u16::from(b)).collect();
|
||||
Some(String::from_utf16_lossy(&buffer))
|
||||
}
|
||||
_ => {
|
||||
let coding = codepage_strings::Coding::new(code_page).ok()?;
|
||||
Some(coding.decode(buffer).ok()?.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_rtf_compressed(buffer: &[u8]) -> Option<String> {
|
||||
decompress_rtf(buffer).ok()
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//
|
||||
// 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 dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::Input;
|
||||
use mail_send::mail_builder::headers::text::Text;
|
||||
use mail_send::mail_builder::MessageBuilder;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
use crate::api::sender::send_batch_request;
|
||||
use crate::pst::encoding::decode_subject;
|
||||
use crate::BichonCliConfig;
|
||||
use bichon_core::base64_encode_url_safe;
|
||||
use dialoguer::Confirm;
|
||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||
use outlook_pst::messaging::folder::Folder;
|
||||
use outlook_pst::messaging::message::{Message, MessageProperties};
|
||||
use outlook_pst::ndb::node_id::NodeId;
|
||||
use reqwest::Client;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
|
||||
mod encoding;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailMetadata {
|
||||
pub message_id: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<Vec<String>>,
|
||||
pub cc: Option<Vec<String>>,
|
||||
pub bcc: Option<Vec<String>>,
|
||||
pub html: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailAttachment {
|
||||
pub name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn handle_pst_import(config: &BichonCliConfig, account_id: u64, theme: &ColorfulTheme) {
|
||||
let path_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the path to your SINGLE .pst file")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if !p.exists() {
|
||||
return Err("The specified path does not exist.");
|
||||
}
|
||||
|
||||
if !p.is_file() {
|
||||
return Err("PST mode requires a SINGLE file, not a directory.");
|
||||
}
|
||||
let is_pst = p
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("pst"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_pst {
|
||||
return Err("The selected file must have a .pst extension.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let pst_path = std::path::PathBuf::from(path_str);
|
||||
|
||||
println!(
|
||||
"\n{} Ready to process PST file: {}",
|
||||
console::style("✔").green(),
|
||||
console::style(pst_path.display()).cyan()
|
||||
);
|
||||
|
||||
if let Ok(meta) = std::fs::metadata(&pst_path) {
|
||||
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
|
||||
println!(
|
||||
"{}",
|
||||
console::style(format!("PST File Size: {:.1} MB", size_mb)).dim()
|
||||
);
|
||||
}
|
||||
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt("Start importing emails from this PST?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
parse_pst(pst_path, config, account_id).await;
|
||||
} else {
|
||||
println!("{}", console::style("Operation cancelled by user.").red());
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_pst(pst_path: PathBuf, config: &BichonCliConfig, account_id: u64) {
|
||||
let client = Client::new();
|
||||
|
||||
let pst_store = match outlook_pst::open_store(&pst_path) {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Failed to open PST file: {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Could not find IPM_SUBTREE (Mailbox Root): {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
|
||||
Ok(folder) => folder,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{} Failed to open the root mailbox folder: {}",
|
||||
console::style("✘").red(),
|
||||
console::style(format!("{:#?}", e)).dim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
process_folder_recursively(&client, &ipm_subtree_folder, "", config, account_id).await;
|
||||
}
|
||||
|
||||
fn process_folder_recursively<'a>(
|
||||
client: &'a Client,
|
||||
folder: &'a Rc<dyn Folder>,
|
||||
parent_path: &'a str,
|
||||
config: &'a BichonCliConfig,
|
||||
account_id: u64,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
|
||||
Box::pin(async move {
|
||||
let folder_name = folder
|
||||
.properties()
|
||||
.display_name()
|
||||
.unwrap_or_else(|_| "Unknown".to_string());
|
||||
|
||||
let current_path = if parent_path.is_empty() {
|
||||
folder_name
|
||||
} else {
|
||||
format!("{}/{}", parent_path, folder_name)
|
||||
};
|
||||
|
||||
println!(
|
||||
"{} {}",
|
||||
console::style("📁 Folder:").dim(),
|
||||
console::style(¤t_path).cyan()
|
||||
);
|
||||
|
||||
let mut emls_batch = Vec::new();
|
||||
|
||||
if let Some(contents_table) = folder.contents_table() {
|
||||
for row in contents_table.rows_matrix() {
|
||||
let store = folder.store().clone();
|
||||
|
||||
let entry_id = match store
|
||||
.properties()
|
||||
.make_entry_id(NodeId::from(u32::from(row.id())))
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {} Skip row {}: {:?}",
|
||||
console::style("⚠").yellow(),
|
||||
row.unique(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match store.open_message(&entry_id, None) {
|
||||
Ok(message) => match build_eml_base64(message) {
|
||||
Some(base64_eml) => emls_batch.push(base64_eml),
|
||||
None => {}
|
||||
},
|
||||
Err(e) => eprintln!(" {} Open error: {:?}", console::style("⚠").yellow(), e),
|
||||
}
|
||||
|
||||
if emls_batch.len() >= 50 {
|
||||
let batch = emls_batch.clone();
|
||||
emls_batch.clear();
|
||||
send_to_bichon(client, config, account_id, ¤t_path, batch).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !emls_batch.is_empty() {
|
||||
send_to_bichon(client, config, account_id, ¤t_path, emls_batch).await;
|
||||
}
|
||||
|
||||
if let Some(hierarchy_table) = folder.hierarchy_table() {
|
||||
for row in hierarchy_table.rows_matrix() {
|
||||
let node = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
|
||||
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
|
||||
process_folder_recursively(
|
||||
client,
|
||||
&sub_folder,
|
||||
¤t_path,
|
||||
config,
|
||||
account_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
|
||||
let properties = message.properties();
|
||||
|
||||
let mut builder = MessageBuilder::new();
|
||||
if let Some(sub) = extract_subject(properties) {
|
||||
builder = builder.subject(sub);
|
||||
}
|
||||
if let Some(mid) = extract_string_property(properties, 0x1035) {
|
||||
builder = builder.message_id(mid);
|
||||
}
|
||||
if let Some(irt) = extract_string_property(properties, 0x1042) {
|
||||
builder = builder.in_reply_to(irt);
|
||||
}
|
||||
|
||||
if let Some(refs) = extract_string_property(properties, 0x1039) {
|
||||
builder = builder.header("References", Text::new(refs));
|
||||
}
|
||||
|
||||
if let Some(cid_val) = properties.get(0x3013) {
|
||||
if let PropertyValue::Binary(bin) = cid_val {
|
||||
builder = builder.header(
|
||||
"X-Bichon-Conversation-ID",
|
||||
Text::new(hex::encode(bin.buffer())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let from = extract_string_property(properties, 0x5D01)
|
||||
.or_else(|| extract_string_property(properties, 0x5D02))
|
||||
.or_else(|| extract_string_property(properties, 0x0C1F));
|
||||
|
||||
if let Some(f) = from {
|
||||
builder = builder.from(f);
|
||||
}
|
||||
|
||||
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
|
||||
let dt = filetime_to_datetime(filetime).timestamp();
|
||||
builder = builder.date(dt);
|
||||
}
|
||||
|
||||
let (to, cc, bcc) = extract_recipients_list(&message);
|
||||
if !to.is_empty() {
|
||||
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !cc.is_empty() {
|
||||
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !bcc.is_empty() {
|
||||
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
if let Some(html) = extract_html(properties) {
|
||||
builder = builder.html_body(html);
|
||||
}
|
||||
|
||||
if let Some(text) = extract_text(properties) {
|
||||
builder = builder.text_body(text);
|
||||
}
|
||||
|
||||
if let Some(attachment_table) = message.attachment_table() {
|
||||
for row in attachment_table.rows_matrix() {
|
||||
let node_id = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
|
||||
let att_props = attachment.properties();
|
||||
let name = extract_attachment_string_property(att_props, 0x3707);
|
||||
let mime = extract_attachment_string_property(att_props, 0x370E)
|
||||
.unwrap_or_else(|| "application/octet-stream".into());
|
||||
let cid = extract_attachment_string_property(att_props, 0x3712);
|
||||
let is_inline = att_props
|
||||
.get(0x3714)
|
||||
.and_then(|val| {
|
||||
if let PropertyValue::Integer32(f) = val {
|
||||
Some(f)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|flag| (flag & 0x4) != 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
|
||||
let data = bin.buffer().to_vec();
|
||||
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
|
||||
|
||||
if is_inline && cid.is_some() {
|
||||
let content_id = cid.unwrap();
|
||||
builder = builder.inline(mime, content_id, data);
|
||||
} else {
|
||||
builder = builder.attachment(mime, file_name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match builder.write_to_vec() {
|
||||
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to generate EML: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
|
||||
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
|
||||
let nsecs = (filetime % 10_000_000) * 100;
|
||||
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
|
||||
}
|
||||
|
||||
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let mut to = Vec::new();
|
||||
let mut cc = Vec::new();
|
||||
let mut bcc = Vec::new();
|
||||
|
||||
let recipient_table = message.recipient_table();
|
||||
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 (col, val) in context.columns().iter().zip(cols) {
|
||||
let prop_val = val
|
||||
.as_ref()
|
||||
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
|
||||
match col.prop_id() {
|
||||
0x0C15 => {
|
||||
if let Some(PropertyValue::Integer32(t)) = prop_val {
|
||||
r_type = t;
|
||||
}
|
||||
}
|
||||
0x39FE | 0x3003 => {
|
||||
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
|
||||
email = s;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !email.is_empty() {
|
||||
match r_type {
|
||||
1 => to.push(email),
|
||||
2 => cc.push(email),
|
||||
3 => bcc.push(email),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let receiver = extract_string_property(message.properties(), 0x0076);
|
||||
if let Some(receiver) = receiver {
|
||||
to.push(receiver);
|
||||
}
|
||||
}
|
||||
(to, cc, bcc)
|
||||
}
|
||||
|
||||
async fn send_to_bichon(
|
||||
client: &Client,
|
||||
config: &BichonCliConfig,
|
||||
account_id: u64,
|
||||
folder_path: &str,
|
||||
emls: Vec<String>,
|
||||
) {
|
||||
send_batch_request(client, config, account_id, folder_path, emls).await;
|
||||
}
|
||||
|
||||
fn extract_subject(props: &MessageProperties) -> Option<String> {
|
||||
props.get(0x0037).and_then(|val| decode_subject(val))
|
||||
}
|
||||
|
||||
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_attachment_string_property(
|
||||
properties: &AttachmentProperties,
|
||||
prop_id: u16,
|
||||
) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_string(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_text(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1000).and_then(extract_string).or_else(|| {
|
||||
properties.get(0x1009).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_html(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1013).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => {
|
||||
let code_page = properties
|
||||
.get(0x3FDE)
|
||||
.and_then(|v| {
|
||||
if let PropertyValue::Integer32(cpid) = v {
|
||||
Some(*cpid as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(65001);
|
||||
encoding::decode_html_body(value.buffer(), code_page)
|
||||
}
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
|
||||
for &prop_id in prop_ids {
|
||||
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
|
||||
return Some(*value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// 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 crate::{mbox::run_import, BichonCliConfig};
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||
|
||||
pub async fn handle_thunderbird_import(
|
||||
config: &BichonCliConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
let root_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter your Thunderbird Mail/ImapMail directory")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if p.exists() && p.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Directory not found.")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let root_path = std::path::PathBuf::from(&root_str);
|
||||
println!("{}", style("🔍 Scanning Thunderbird structure...").dim());
|
||||
|
||||
let mut mbox_tasks: HashMap<String, PathBuf> = HashMap::new();
|
||||
|
||||
fn scan_thunderbird_dir(
|
||||
root: &std::path::Path,
|
||||
current: &std::path::Path,
|
||||
tasks: &mut HashMap<String, PathBuf>,
|
||||
) {
|
||||
if let Ok(entries) = std::fs::read_dir(current) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
||||
|
||||
if path.is_dir() {
|
||||
scan_thunderbird_dir(root, &path, tasks);
|
||||
} else {
|
||||
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
match extension {
|
||||
"msf" | "dat" | "html" | "json" | "txt" | "sqlite" => continue,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if file_name == "filterlog.html" || file_name == "msgFilterRules.dat" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !extension.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(rel) = path.strip_prefix(root) {
|
||||
let mailbox = rel.to_string_lossy().replace(".sbd", "").replace('\\', "/");
|
||||
tasks.insert(mailbox, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan_thunderbird_dir(&root_path, &root_path, &mut mbox_tasks);
|
||||
if mbox_tasks.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
style("No mailboxes found in the specified directory.").yellow()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n{}", style("🔍 Scanned Mailboxes:").bold().underlined());
|
||||
let mut sorted_keys: Vec<_> = mbox_tasks.keys().collect();
|
||||
sorted_keys.sort();
|
||||
|
||||
for name in &sorted_keys {
|
||||
let path = &mbox_tasks[*name];
|
||||
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
let size_mb = file_size as f64 / 1024.0 / 1024.0;
|
||||
|
||||
println!(
|
||||
" {} {} ({:.2} MB)",
|
||||
style("•").dim(),
|
||||
style(name).cyan(),
|
||||
size_mb
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
let prompt = format!("Ready to import {} mailboxes. Proceed?", mbox_tasks.len());
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt(prompt)
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
for (mailbox_name, mbox_file) in mbox_tasks {
|
||||
println!("\n🚀 Importing: {}", style(&mailbox_name).cyan().bold());
|
||||
run_import(account_id, &mbox_file, config, Some(mailbox_name)).await;
|
||||
}
|
||||
println!(
|
||||
"\n{}",
|
||||
style("✨ All mailboxes imported successfully!")
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
} else {
|
||||
println!("{}", style("Import cancelled.").yellow());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,19 +16,25 @@
|
||||
// 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::{encrypt, error::BichonResult};
|
||||
|
||||
use crate::{encrypt, modules::error::BichonResult};
|
||||
|
||||
use poem_openapi::{Enum, Object};
|
||||
//use poem_openapi::{Enum, Object};
|
||||
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 {
|
||||
/// 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,
|
||||
/// 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,
|
||||
/// Connection encryption method
|
||||
pub encryption: Encryption,
|
||||
@@ -52,8 +58,8 @@ impl ImapConfig {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Enum, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
|
||||
pub enum AuthType {
|
||||
/// Standard password authentication (PLAIN/LOGIN)
|
||||
#[default]
|
||||
@@ -62,7 +68,8 @@ pub enum AuthType {
|
||||
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 {
|
||||
///Authentication method to use
|
||||
pub auth_type: AuthType,
|
||||
@@ -70,7 +77,7 @@ pub struct AuthConfig {
|
||||
///
|
||||
/// Users should provide a plaintext password (1 to 256 characters).
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// SSL/TLS encrypted connection
|
||||
#[default]
|
||||
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// 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 crate::{
|
||||
raise_error, utc_now,
|
||||
{
|
||||
account::migration::AccountModel,
|
||||
common::auth::ClientContext,
|
||||
database::{manager::DB_MANAGER, with_transaction, MemDbModel},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
users::{
|
||||
permissions::Permission,
|
||||
role::{RoleType, UserRole},
|
||||
UserModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
pub struct BatchAccountRoleRequest {
|
||||
pub account_ids: Vec<u64>,
|
||||
pub user_ids: Vec<u64>,
|
||||
pub role_id: u64,
|
||||
}
|
||||
|
||||
impl BatchAccountRoleRequest {
|
||||
pub fn validate_existence(&self) -> BichonResult<()> {
|
||||
let role = UserRole::find(self.role_id)?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("Role ID {} not found", self.role_id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
if !matches!(role.role_type, RoleType::Account) {
|
||||
return Err(raise_error!(
|
||||
"Only Account roles can be assigned to individual account".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
for id in &self.account_ids {
|
||||
let exists = AccountModel::find(*id)?; // Assuming an exists helper
|
||||
if exists.is_none() {
|
||||
return Err(raise_error!(
|
||||
format!("Account ID {} not found", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for id in &self.user_ids {
|
||||
let exists = UserModel::find(*id)?; // Assuming an exists helper
|
||||
if exists.is_none() {
|
||||
return Err(raise_error!(
|
||||
format!("User ID {} not found", id),
|
||||
ErrorCode::ResourceNotFound
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn grant_batch_account_access(
|
||||
account_ids: Vec<u64>,
|
||||
user_ids: Vec<u64>,
|
||||
role_id: u64,
|
||||
) -> BichonResult<()> {
|
||||
with_transaction(DB_MANAGER.db(), move |txn| {
|
||||
let mut txn = txn;
|
||||
for &uid in &user_ids {
|
||||
let db = DB_MANAGER.db();
|
||||
let coll = db.collection(UserModel::collection());
|
||||
let key = uid.to_string();
|
||||
let user: UserModel = coll
|
||||
.get_required(&key)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
let mut updated_user = user.clone();
|
||||
|
||||
for &aid in &account_ids {
|
||||
updated_user.account_access_map.insert(aid, role_id);
|
||||
}
|
||||
|
||||
updated_user.updated_at = utc_now!();
|
||||
|
||||
txn = txn
|
||||
.upsert(UserModel::collection(), key, &updated_user)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
}
|
||||
Ok(txn)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
|
||||
for account_id in &self.account_ids {
|
||||
// Get the user's specific access for this account
|
||||
let assigned_role_id =
|
||||
context
|
||||
.user
|
||||
.account_access_map
|
||||
.get(account_id)
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("No access to account {}", account_id),
|
||||
ErrorCode::Forbidden
|
||||
)
|
||||
})?;
|
||||
|
||||
// Fetch the role definition from the database
|
||||
let user_scoped_role = UserRole::find(*assigned_role_id)?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Assigned account role no longer exists".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
|
||||
// Critical Check: Does this role grant management/sharing rights?
|
||||
if !user_scoped_role
|
||||
.permissions
|
||||
.contains(Permission::ACCOUNT_MANAGE)
|
||||
{
|
||||
return Err(raise_error!(
|
||||
format!("Your role on account {} does not allow sharing", account_id),
|
||||
ErrorCode::Forbidden
|
||||
));
|
||||
}
|
||||
|
||||
// Optional: Ensure manager isn't giving away perms they don't have
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,10 +16,12 @@
|
||||
// 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 dispatcher;
|
||||
pub mod entity;
|
||||
pub mod grant;
|
||||
pub mod migration;
|
||||
pub mod old_state;
|
||||
pub mod payload;
|
||||
pub mod since;
|
||||
pub mod state;
|
||||
pub mod migration;
|
||||
pub mod stats;
|
||||
pub mod view;
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,41 +16,71 @@
|
||||
// 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::BTreeSet;
|
||||
|
||||
use crate::modules::account::entity::ImapConfig;
|
||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
||||
use crate::modules::account::since::DateSince;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::token::AccountInfo;
|
||||
use crate::account::entity::ImapConfig;
|
||||
use crate::account::migration::{AccountModel, AccountType, QuotaWindow};
|
||||
use crate::account::since::{DateSince, RelativeDate};
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::error::BichonResult;
|
||||
use crate::{raise_error, validate_email};
|
||||
use poem_openapi::Object;
|
||||
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 {
|
||||
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
|
||||
#[cfg_attr(
|
||||
feature = "web-api",
|
||||
oai(validator(custom = "crate::common::validator::EmailValidator"))
|
||||
)]
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub login_name: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub account_type: AccountType,
|
||||
#[oai(validator(minimum(value = "100")))]
|
||||
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "100"))))]
|
||||
pub folder_limit: Option<u32>,
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
|
||||
pub download_interval_min: Option<i64>,
|
||||
#[cfg_attr(
|
||||
feature = "web-api",
|
||||
oai(validator(minimum(value = "10"), maximum(value = "200")))
|
||||
)]
|
||||
pub download_batch_size: Option<u32>,
|
||||
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 AccountCreateRequest {
|
||||
pub fn create_entity(self) -> BichonResult<AccountModel> {
|
||||
pub fn create_entity(self, user_id: u64) -> BichonResult<AccountModel> {
|
||||
if self.date_before.is_some() && self.date_since.is_some() {
|
||||
return Err(raise_error!(
|
||||
"date_before and date_since are mutually exclusive; specify only one time boundary"
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
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() {
|
||||
date_since.validate()?;
|
||||
}
|
||||
|
||||
if let Some(date_before) = self.date_before.as_ref() {
|
||||
date_before.validate_date()?;
|
||||
}
|
||||
|
||||
match self.account_type {
|
||||
AccountType::IMAP => {
|
||||
match &self.imap {
|
||||
@@ -62,7 +92,7 @@ impl AccountCreateRequest {
|
||||
))
|
||||
}
|
||||
}
|
||||
if self.sync_interval_min.is_none() {
|
||||
if self.download_interval_min.is_none() {
|
||||
return Err(raise_error!(
|
||||
"`sync_interval_min` is required for IMAP account type".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
@@ -71,7 +101,7 @@ impl AccountCreateRequest {
|
||||
}
|
||||
AccountType::NoSync => {}
|
||||
}
|
||||
Ok(AccountModel::new(self)?)
|
||||
Ok(AccountModel::new(user_id, self)?)
|
||||
}
|
||||
|
||||
fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> {
|
||||
@@ -83,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 email: Option<String>,
|
||||
/// Represents the account activation status.
|
||||
@@ -92,8 +123,7 @@ pub struct AccountUpdateRequest {
|
||||
/// and any attempts to access them should return an error indicating the account
|
||||
/// is inactive.
|
||||
pub enabled: Option<bool>,
|
||||
/// Display name for the account (optional)
|
||||
pub name: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
/// IMAP server configuration
|
||||
pub imap: Option<ImapConfig>,
|
||||
/// Controls initial synchronization time range
|
||||
@@ -107,11 +137,14 @@ pub struct AccountUpdateRequest {
|
||||
/// - First-time sync optimization for large accounts
|
||||
/// - Reducing server load during resyncs
|
||||
pub date_since: Option<DateSince>,
|
||||
pub date_before: Option<RelativeDate>,
|
||||
pub clear_date_range: Option<bool>,
|
||||
/// Max emails to sync for this folder.
|
||||
/// If not set, sync all emails.
|
||||
/// 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 clear_folder_limit: Option<bool>,
|
||||
/// Configuration for selective folder (mailbox/label) synchronization
|
||||
///
|
||||
/// - For IMAP/SMTP accounts:
|
||||
@@ -126,9 +159,14 @@ pub struct AccountUpdateRequest {
|
||||
/// Defaults to standard folders (`INBOX`, `Sent`) if empty.
|
||||
/// Modified folders will be automatically synced on the next update.
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
/// Incremental sync interval (seconds)
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
/// Incremental download interval (seconds)
|
||||
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
|
||||
pub download_interval_min: Option<i64>,
|
||||
#[cfg_attr(
|
||||
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).
|
||||
/// - 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.
|
||||
@@ -137,13 +175,52 @@ pub struct AccountUpdateRequest {
|
||||
pub use_dangerous: Option<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 AccountUpdateRequest {
|
||||
pub fn validate_update_request(&self, account: &AccountModel) -> BichonResult<()> {
|
||||
if self.date_before.is_some() && self.date_since.is_some() {
|
||||
return Err(raise_error!(
|
||||
"date_before and date_since are mutually exclusive; specify only one time boundary"
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
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() {
|
||||
return Err(raise_error!(
|
||||
"clear_folder_limit cannot be combined with folder_limit".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if self.clear_date_range == Some(true)
|
||||
&& (self.date_since.is_some() || self.date_before.is_some())
|
||||
{
|
||||
return Err(raise_error!(
|
||||
"clear_date_range cannot be combined with date_since or date_before".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(date_since) = self.date_since.as_ref() {
|
||||
date_since.validate()?;
|
||||
}
|
||||
|
||||
if let Some(date_before) = self.date_before.as_ref() {
|
||||
date_before.validate_date()?;
|
||||
}
|
||||
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
if let Some(mailboxes) = self.sync_folders.as_ref() {
|
||||
if mailboxes.is_empty() {
|
||||
@@ -158,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 id: u64,
|
||||
@@ -167,11 +245,11 @@ pub struct MinimalAccount {
|
||||
|
||||
pub fn filter_accessible_accounts<'a>(
|
||||
all_accounts: &'a [MinimalAccount],
|
||||
allowed: &BTreeSet<AccountInfo>,
|
||||
allowed: &Vec<u64>,
|
||||
) -> Vec<MinimalAccount> {
|
||||
all_accounts
|
||||
.iter()
|
||||
.filter(|acct| allowed.iter().any(|a| a.id == acct.id))
|
||||
.filter(|acct| allowed.contains(&acct.id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,16 +16,15 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
modules::error::{code::ErrorCode, BichonResult},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
raise_error,
|
||||
};
|
||||
use chrono::{Datelike, Days, Local, Months, NaiveDate, Utc};
|
||||
use poem_openapi::{Enum, Object};
|
||||
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 {
|
||||
/// Absolute date boundary in ISO 8601 format (YYYY-MM-DD)
|
||||
///
|
||||
@@ -39,7 +38,7 @@ pub struct DateSince {
|
||||
/// "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>,
|
||||
/// Relative time period from current date
|
||||
///
|
||||
@@ -59,7 +58,8 @@ pub struct DateSince {
|
||||
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 {
|
||||
#[default]
|
||||
Days,
|
||||
@@ -67,12 +67,13 @@ pub enum Unit {
|
||||
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 {
|
||||
/// The time unit to use for the offset (days, months, or years)
|
||||
pub unit: Unit,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -256,19 +257,47 @@ impl DateSince {
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::modules::account::since::{DateSince, RelativeDate, Unit};
|
||||
use crate::account::since::{DateSince, RelativeDate, Unit};
|
||||
|
||||
#[test]
|
||||
fn test1() {
|
||||
fn fixed_date_valid() {
|
||||
let e = DateSince {
|
||||
fixed: Some("2014-09-12".to_string()),
|
||||
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 {
|
||||
fixed: None,
|
||||
relative: Some(RelativeDate {
|
||||
@@ -276,9 +305,63 @@ mod test {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,10 +16,11 @@
|
||||
// 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};
|
||||
|
||||
export interface Proxy {
|
||||
id: number;
|
||||
url: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
pub struct AccountStats {
|
||||
pub total_size: u64,
|
||||
pub total_count: u64,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// 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::{BTreeSet, HashMap};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
account::{
|
||||
entity::ImapConfig,
|
||||
migration::{AccountModel, AccountType, QuotaWindow},
|
||||
since::{DateSince, RelativeDate},
|
||||
},
|
||||
users::UserModel,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
pub struct AccountResp {
|
||||
pub id: u64,
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
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 created_user_name: String,
|
||||
pub created_user_email: String,
|
||||
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 AccountResp {
|
||||
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, UserModel>) -> AccountResp {
|
||||
let user = user_map.get(&account.created_by);
|
||||
AccountResp {
|
||||
id: account.id,
|
||||
imap: account.imap,
|
||||
enabled: account.enabled,
|
||||
email: account.email,
|
||||
account_name: account.account_name,
|
||||
login_name: account.login_name,
|
||||
capabilities: account.capabilities,
|
||||
date_since: account.date_since,
|
||||
date_before: account.date_before,
|
||||
folder_limit: account.folder_limit,
|
||||
download_folders: account.download_folders,
|
||||
account_type: account.account_type,
|
||||
download_interval_min: account.download_interval_min,
|
||||
download_batch_size: account.download_batch_size,
|
||||
known_folders: account.known_folders,
|
||||
created_at: account.created_at,
|
||||
updated_at: account.updated_at,
|
||||
created_by: account.created_by,
|
||||
created_user_name: user
|
||||
.map(|u| u.username.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
created_user_email: user
|
||||
.map(|u| u.email.clone())
|
||||
.unwrap_or_else(|| "N/A".to_string()),
|
||||
use_proxy: account.use_proxy,
|
||||
use_dangerous: account.use_dangerous,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -17,4 +17,4 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
pub mod extractor;
|
||||
pub mod meta;
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,14 +16,12 @@
|
||||
// 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 autoconfig::config::OAuth2Config as XOAuth2Config;
|
||||
use poem_openapi::Object;
|
||||
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 {
|
||||
/// server hostname or IP address
|
||||
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 {
|
||||
/// The authorization server's issuer identifier URL
|
||||
pub issuer: String,
|
||||
@@ -54,19 +53,8 @@ pub struct OAuth2Config {
|
||||
/// URL of the authorization server's token endpoint
|
||||
pub token_url: String,
|
||||
}
|
||||
|
||||
impl From<&XOAuth2Config> for OAuth2Config {
|
||||
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)]
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
pub struct MailServerConfig {
|
||||
/// IMAP server configuration
|
||||
pub imap: ServerConfig,
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,18 +16,13 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::database::manager::DB_MANAGER;
|
||||
use crate::modules::database::{delete_impl, async_find_impl, upsert_impl};
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
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 crate::database::manager::DB_MANAGER;
|
||||
use crate::database::{delete_impl, upsert_impl};
|
||||
use crate::database::{find_impl, MemDbModel};
|
||||
use crate::{autoconfig::entity::MailServerConfig, error::BichonResult, utc_now};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod client;
|
||||
pub mod entity;
|
||||
pub mod load;
|
||||
#[cfg(test)]
|
||||
@@ -36,45 +31,39 @@ mod tests;
|
||||
const EXPIRE_TIME_MS: i64 = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
#[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 MemDbModel for CachedMailSettings {
|
||||
fn collection() -> &'static str {
|
||||
"autoconfig"
|
||||
}
|
||||
fn key(&self) -> String {
|
||||
self.domain.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl CachedMailSettings {
|
||||
pub async fn add(domain: String, config: MailServerConfig) -> BichonResult<()> {
|
||||
pub fn add(domain: String, config: MailServerConfig) -> BichonResult<()> {
|
||||
Self {
|
||||
domain,
|
||||
config,
|
||||
created_at: utc_now!(),
|
||||
}
|
||||
.save()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save(&self) -> BichonResult<()> {
|
||||
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
|
||||
fn save(&self) -> BichonResult<()> {
|
||||
upsert_impl(DB_MANAGER.db(), self.to_owned())
|
||||
}
|
||||
|
||||
pub async fn get(domain: &str) -> BichonResult<Option<CachedMailSettings>> {
|
||||
if let Some(found) =
|
||||
async_find_impl::<CachedMailSettings>(DB_MANAGER.meta_db(), domain.to_string()).await?
|
||||
{
|
||||
pub fn get(domain: &str) -> BichonResult<Option<CachedMailSettings>> {
|
||||
if let Some(found) = find_impl::<CachedMailSettings>(DB_MANAGER.db(), domain)? {
|
||||
if (utc_now!() - found.created_at) > EXPIRE_TIME_MS {
|
||||
let domain = domain.to_string();
|
||||
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?;
|
||||
delete_impl::<CachedMailSettings>(DB_MANAGER.db(), domain)?;
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(found))
|
||||
@@ -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");
|
||||
}
|
||||
+33
-21
@@ -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
|
||||
//
|
||||
@@ -16,27 +16,28 @@
|
||||
// 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::BTreeSet;
|
||||
|
||||
use crate::{
|
||||
decode_mailbox_name,
|
||||
modules::{
|
||||
decode_mailbox_name, raise_error,
|
||||
{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
cache::imap::mailbox::{AttributeEnum, MailBox},
|
||||
context::executors::MAIL_CONTEXT,
|
||||
cache::imap::mailbox_cache,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
imap::{executor::ImapExecutor, session::SessionStream},
|
||||
mailbox::list::convert_names_to_mailboxes,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use async_imap::types::Name;
|
||||
use async_imap::{types::Name, Session};
|
||||
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);
|
||||
let executor = MAIL_CONTEXT.imap(account.id).await?;
|
||||
let names = executor.list_all_mailboxes().await?;
|
||||
let names = ImapExecutor::list_all_mailboxes(session).await?;
|
||||
if names.is_empty() {
|
||||
warn!(
|
||||
"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(),
|
||||
)
|
||||
.await?;
|
||||
let account = AccountModel::get(account.id).await?;
|
||||
let subscribed = &account.sync_folders.unwrap_or_default();
|
||||
let account = AccountModel::get(account.id)?;
|
||||
let subscribed = &account.download_folders.unwrap_or_default();
|
||||
let is_noselect = |mailbox: &MailBox| {
|
||||
mailbox
|
||||
.attributes
|
||||
@@ -109,7 +110,7 @@ pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBo
|
||||
.iter()
|
||||
.map(|n| decode_mailbox_name!(n.name().to_string()))
|
||||
.collect();
|
||||
AccountModel::update_sync_folders(account.id, sync_folders).await?;
|
||||
AccountModel::update_download_folders(account.id, sync_folders)?;
|
||||
} else {
|
||||
warn!(
|
||||
"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));
|
||||
}
|
||||
}
|
||||
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(
|
||||
@@ -130,7 +131,7 @@ pub async fn detect_mailbox_changes(
|
||||
) -> BichonResult<()> {
|
||||
if account.known_folders.is_none() {
|
||||
// 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(());
|
||||
}
|
||||
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 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
|
||||
if !deleted_folders.is_empty() {
|
||||
// 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()
|
||||
.filter(|folder| !deleted_folders.contains(folder))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// If sync_folders changed, update them
|
||||
if remaining_sync_folders.len() != sync_folders.len() {
|
||||
let removed_count = sync_folders.len() - remaining_sync_folders.len();
|
||||
if remaining_sync_folders.len() != download_folders.len() {
|
||||
let removed_count = download_folders.len() - remaining_sync_folders.len();
|
||||
info!(
|
||||
"Account {}: Removed {} deleted folders from sync_folders",
|
||||
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),
|
||||
// the system's default behavior is to automatically fall back to syncing
|
||||
// only the default folders (INBOX and Sent) in subsequent operations
|
||||
AccountModel::update_sync_folders(account.id, remaining_sync_folders).await?;
|
||||
AccountModel::update_download_folders(account.id, remaining_sync_folders)?;
|
||||
}
|
||||
|
||||
info!(
|
||||
@@ -174,11 +175,22 @@ pub async fn detect_mailbox_changes(
|
||||
"Account {}: New folders detected: {:?}",
|
||||
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
|
||||
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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
+52
-76
@@ -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
|
||||
//
|
||||
@@ -16,34 +16,25 @@
|
||||
// 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::{
|
||||
decode_mailbox_name, encode_mailbox_name,
|
||||
modules::{
|
||||
decode_mailbox_name, encode_mailbox_name, raise_error,
|
||||
{
|
||||
database::{
|
||||
batch_delete_impl, batch_insert_impl, batch_upsert_impl, filter_by_secondary_key_impl,
|
||||
manager::DB_MANAGER,
|
||||
batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl, filter_impl,
|
||||
find_impl, manager::DB_MANAGER, MemDbModel,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
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};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
#[native_model(id = 1, version = 1)]
|
||||
#[native_db]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
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
|
||||
@@ -67,80 +58,64 @@ pub struct MailBox {
|
||||
pub uid_validity: Option<u32>,
|
||||
}
|
||||
|
||||
impl MemDbModel for MailBox {
|
||||
fn collection() -> &'static str {
|
||||
"mailboxes"
|
||||
}
|
||||
fn key(&self) -> String {
|
||||
self.id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl MailBox {
|
||||
pub fn encoded_name(&self) -> String {
|
||||
encode_mailbox_name!(&self.name)
|
||||
}
|
||||
|
||||
// pub async fn batch_delete(mailboxes: Vec<MailBox>) -> BichonResult<()> {
|
||||
// batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
||||
// 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) -> RustMailerResult<MailBox> {
|
||||
// let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
|
||||
// Ok(result.ok_or_else(|| {
|
||||
// raise_error!(
|
||||
// format!("mailbox {} not found", id),
|
||||
// ErrorCode::InternalError
|
||||
// )
|
||||
// })?)
|
||||
// }
|
||||
|
||||
// pub async fn delete(id: u64) -> BichonResult<()> {
|
||||
// delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
||||
// 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>> {
|
||||
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id)
|
||||
.await
|
||||
pub fn get(id: u64) -> BichonResult<MailBox> {
|
||||
let result = find_impl::<MailBox>(DB_MANAGER.db(), &id.to_string())?;
|
||||
Ok(result.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("mailbox {} not found", id),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
||||
batch_insert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
|
||||
pub fn delete(id: u64) -> BichonResult<()> {
|
||||
delete_impl::<MailBox>(DB_MANAGER.db(), &id.to_string())
|
||||
}
|
||||
|
||||
pub async fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
||||
batch_upsert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
|
||||
pub fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
|
||||
filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)
|
||||
}
|
||||
|
||||
pub async fn clean(account_id: u64) -> BichonResult<()> {
|
||||
batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
||||
let mailboxes: Vec<MailBox> = rw
|
||||
.scan()
|
||||
.secondary::<MailBox>(MailBoxKey::account_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.start_with(account_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.try_collect()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(mailboxes)
|
||||
})
|
||||
.await?;
|
||||
pub fn find_mailbox(account_id: u64, mailbox_id: u64) -> BichonResult<Option<MailBox>> {
|
||||
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 fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
||||
batch_insert_impl(DB_MANAGER.db(), mailboxes.to_vec())
|
||||
}
|
||||
|
||||
pub fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
||||
batch_upsert_impl(DB_MANAGER.db(), mailboxes.to_vec())
|
||||
}
|
||||
|
||||
pub fn clean(account_id: u64) -> BichonResult<()> {
|
||||
let mailboxes =
|
||||
filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)?;
|
||||
let keys: Vec<String> = mailboxes.iter().map(|m| m.id.to_string()).collect();
|
||||
if !keys.is_empty() {
|
||||
batch_delete_impl::<MailBox>(DB_MANAGER.db(), keys)?;
|
||||
}
|
||||
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 attr: AttributeEnum,
|
||||
pub extension: Option<String>,
|
||||
@@ -152,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 {
|
||||
NoInferiors,
|
||||
NoSelect,
|
||||
+114
@@ -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
|
||||
//
|
||||
@@ -16,30 +16,20 @@
|
||||
// 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, HashSet};
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::modules::{account::state::AccountRunningState, database::ModelsAdapter};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use mailbox::MailBox;
|
||||
use native_db::Models;
|
||||
|
||||
pub mod download;
|
||||
pub mod mailbox;
|
||||
pub mod sync;
|
||||
pub mod mailbox_cache;
|
||||
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(
|
||||
local_mailboxes: &[MailBox],
|
||||
server_mailboxes: &[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
|
||||
.iter()
|
||||
.filter(|m| !local_names.contains(&m.name))
|
||||
@@ -51,7 +41,7 @@ pub fn find_intersecting_mailboxes(
|
||||
local_mailboxes: &[MailBox],
|
||||
remote_mailboxes: &[MailBox],
|
||||
) -> Vec<(MailBox, MailBox)> {
|
||||
let local_map: AHashMap<_, _> = local_mailboxes
|
||||
let local_map: HashMap<_, _> = local_mailboxes
|
||||
.iter()
|
||||
.map(|m| (m.name.clone(), m.clone()))
|
||||
.collect();
|
||||
Vendored
+276
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
-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
|
||||
//
|
||||
@@ -17,7 +17,7 @@
|
||||
// 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 tokio::sync::Semaphore;
|
||||
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,29 +16,19 @@
|
||||
// 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 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 tracing::error;
|
||||
|
||||
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod log;
|
||||
pub mod paginated;
|
||||
pub mod periodic;
|
||||
pub mod rustls;
|
||||
pub mod signal;
|
||||
pub mod timeout;
|
||||
pub mod tls;
|
||||
#[cfg(feature = "web-api")]
|
||||
pub mod validator;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Object)]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub struct Addr {
|
||||
/// The optional display name associated with the email address (e.g., "John Doe").
|
||||
/// If `None`, no display name is specified.
|
||||
@@ -91,62 +81,3 @@ impl<'x> From<&ImapAddress<'x>> for AddrVec {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,10 +16,9 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::{common::signal::SIGNAL_MANAGER, error::BichonResult};
|
||||
use crate::{common::signal::SIGNAL_MANAGER, error::BichonResult};
|
||||
use std::{future::Future, time::Duration};
|
||||
use tokio::{sync::oneshot, time::MissedTickBehavior};
|
||||
use tokio::{sync::oneshot, task::JoinHandle, time::MissedTickBehavior};
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct PeriodicTask {
|
||||
@@ -28,7 +27,7 @@ pub struct PeriodicTask {
|
||||
|
||||
pub struct TaskHandle {
|
||||
cancel_sender: Option<oneshot::Sender<()>>,
|
||||
join_handle: tokio::task::JoinHandle<()>,
|
||||
join_handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TaskHandle {
|
||||
@@ -38,6 +37,10 @@ impl TaskHandle {
|
||||
}
|
||||
let _ = self.join_handle.await;
|
||||
}
|
||||
|
||||
pub async fn stop(self) {
|
||||
let _ = self.join_handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
impl PeriodicTask {
|
||||
@@ -82,6 +85,14 @@ impl PeriodicTask {
|
||||
let mut cancel_receiver = cancel_receiver_opt;
|
||||
|
||||
loop {
|
||||
let cancel_fut = async {
|
||||
if let Some(ref mut rx) = cancel_receiver {
|
||||
rx.await.ok();
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
match task(param).await {
|
||||
@@ -92,13 +103,7 @@ impl PeriodicTask {
|
||||
}
|
||||
}
|
||||
// only enabled if cancel_receiver is Some
|
||||
_ = async {
|
||||
if let Some(ref mut rx) = cancel_receiver {
|
||||
rx.await.ok()
|
||||
} else {
|
||||
futures::future::pending().await
|
||||
}
|
||||
} => {
|
||||
_ = cancel_fut => {
|
||||
info!("Task '{}' received cancellation signal", name_clone);
|
||||
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
|
||||
//
|
||||
@@ -16,18 +16,17 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
{
|
||||
context::Initialize,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
pub struct RustMailerTls;
|
||||
pub struct BichonTls;
|
||||
|
||||
impl Initialize for RustMailerTls {
|
||||
impl Initialize for BichonTls {
|
||||
async fn initialize() -> BichonResult<()> {
|
||||
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
|
||||
.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
|
||||
//
|
||||
@@ -16,12 +16,9 @@
|
||||
// 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::sync::LazyLock;
|
||||
|
||||
use crate::modules::{
|
||||
context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal,
|
||||
};
|
||||
use crate::{context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
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
|
||||
//
|
||||
@@ -23,6 +23,7 @@ use std::{
|
||||
};
|
||||
|
||||
use email_address::EmailAddress;
|
||||
|
||||
use poem_openapi::Validator;
|
||||
|
||||
pub struct EmailValidator;
|
||||
@@ -33,6 +34,7 @@ impl Display for EmailValidator {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Validator<String> for EmailValidator {
|
||||
fn check(&self, value: &String) -> bool {
|
||||
match EmailAddress::from_str(value) {
|
||||
@@ -40,4 +42,4 @@ impl Validator<String> for EmailValidator {
|
||||
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
|
||||
//
|
||||
@@ -16,30 +16,29 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::{cache::imap::task::SYNC_TASKS, error::BichonResult};
|
||||
use crate::{cache::imap::task::SYNC_TASKS, error::BichonResult};
|
||||
use std::{sync::LazyLock, time::Duration};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub static SYNC_CONTROLLER: LazyLock<SyncController> = LazyLock::new(SyncController::new);
|
||||
pub static DOWNLOAD_CONTROLLER: LazyLock<DownloadController> =
|
||||
LazyLock::new(DownloadController::new);
|
||||
|
||||
pub struct SyncController {
|
||||
channel: mpsc::Sender<(u64, String)>, // Channel to trigger account sync by account ID
|
||||
pub struct DownloadController {
|
||||
channel: mpsc::Sender<(u64, String)>, // Channel to trigger account download by account ID
|
||||
}
|
||||
|
||||
impl SyncController {
|
||||
impl DownloadController {
|
||||
pub fn new() -> Self {
|
||||
let (tx, mut rx) = mpsc::channel::<(u64, String)>(100);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some((account_id, email)) = rx.recv().await {
|
||||
match Self::start_syncer(account_id, email.clone()).await {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {}
|
||||
match Self::start_download(account_id, email.clone()).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to prepare and start syncer of account {{{}-{}}}, error: {:#?}",
|
||||
"Failed to prepare and start scheduled download of account {{{}-{}}}, error: {:#?}",
|
||||
&account_id, &email, err
|
||||
);
|
||||
}
|
||||
@@ -47,26 +46,26 @@ impl SyncController {
|
||||
}
|
||||
});
|
||||
|
||||
SyncController { channel: tx }
|
||||
DownloadController { channel: tx }
|
||||
}
|
||||
|
||||
/// Trigger synchronization for a specific account
|
||||
pub async fn trigger_start(&self, account_id: u64, email: String) {
|
||||
pub async fn trigger_schedule(&self, account_id: u64, email: String) {
|
||||
if let Err(e) = self.channel.send((account_id, email)).await {
|
||||
error!(
|
||||
"Failed to trigger synchronization for account={{{}}}, error: {:?}",
|
||||
"Failed to trigger download for account={{{}}}, error: {:?}",
|
||||
account_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_syncer(account_id: u64, email: String) -> BichonResult<Option<()>> {
|
||||
async fn start_download(account_id: u64, email: String) -> BichonResult<()> {
|
||||
info!(
|
||||
"Account syncer starting for account: {}-{}.",
|
||||
"Account download starting for account: {}-{}.",
|
||||
account_id, email
|
||||
);
|
||||
SYNC_TASKS.start_account_sync_task(account_id, email).await;
|
||||
SYNC_TASKS.start_download_task(account_id, email).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
Ok(Some(()))
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,17 +16,16 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::{common::periodic::TaskHandle, error::BichonResult};
|
||||
|
||||
pub mod controller;
|
||||
pub mod executors;
|
||||
pub mod status;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait Initialize {
|
||||
async fn initialize() -> BichonResult<()>;
|
||||
}
|
||||
|
||||
pub trait RustMailTask {
|
||||
fn start();
|
||||
pub trait BichonTask {
|
||||
fn start() -> TaskHandle;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,8 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
pub mod cli;
|
||||
pub mod dir;
|
||||
pub mod proxy;
|
||||
pub mod system;
|
||||
pub mod extractor;
|
||||
pub mod meta;
|
||||
pub mod utils;
|
||||
@@ -0,0 +1,175 @@
|
||||
//
|
||||
// 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 regex::{Captures, Regex};
|
||||
|
||||
fn merge_contiguous_encoded_words(input: &str) -> String {
|
||||
let block_re =
|
||||
Regex::new(r"(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=)(?:\s+(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=))+")
|
||||
.unwrap();
|
||||
|
||||
let word_re = Regex::new(r"=\?([^?]+)\?([bBqQ])\?([^?]+)\?=").unwrap();
|
||||
|
||||
block_re
|
||||
.replace_all(input, |caps: &Captures| {
|
||||
let whole = caps.get(0).unwrap().as_str();
|
||||
|
||||
let mut charset: Option<String> = None;
|
||||
let mut encoding: Option<String> = None;
|
||||
let mut combined = String::new();
|
||||
let mut ok = true;
|
||||
|
||||
for cap in word_re.captures_iter(whole) {
|
||||
let cs = &cap[1];
|
||||
let enc = cap[2].to_ascii_uppercase();
|
||||
let text = &cap[3];
|
||||
|
||||
if let Some(ref c) = charset {
|
||||
if c != cs {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
charset = Some(cs.to_string());
|
||||
}
|
||||
|
||||
if let Some(ref e) = encoding {
|
||||
if e != &enc {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
encoding = Some(enc);
|
||||
}
|
||||
|
||||
combined.push_str(text);
|
||||
}
|
||||
|
||||
if ok {
|
||||
format!(
|
||||
"=?{}?{}?{}?=",
|
||||
charset.unwrap(),
|
||||
encoding.unwrap(),
|
||||
combined
|
||||
)
|
||||
} else {
|
||||
whole.to_string()
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn normalize_subject(raw_subject: Option<&str>) -> String {
|
||||
let subject = match raw_subject {
|
||||
Some(subject) => merge_contiguous_encoded_words(subject),
|
||||
None => return String::new(),
|
||||
};
|
||||
|
||||
MessageStream::new(subject.as_bytes())
|
||||
.parse_unstructured()
|
||||
.as_text()
|
||||
.map(String::from)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::envelope::utils::{merge_contiguous_encoded_words, normalize_subject};
|
||||
|
||||
// ── merge_contiguous_encoded_words ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn merge_basic_utf8_b() {
|
||||
let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!";
|
||||
assert_eq!(
|
||||
merge_contiguous_encoded_words(s),
|
||||
"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==?=";
|
||||
assert_eq!(
|
||||
merge_contiguous_encoded_words(s),
|
||||
"=?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==?=";
|
||||
assert_eq!(
|
||||
merge_contiguous_encoded_words(s),
|
||||
"=?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==?=";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_different_encodings() {
|
||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?=";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_case_insensitive_encoding() {
|
||||
let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?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=?= !!!";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_spaces_between_words() {
|
||||
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?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";
|
||||
assert_eq!(merge_contiguous_encoded_words(s), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_quoted_printable() {
|
||||
let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?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), "");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,9 +16,9 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::imap::session::SessionStream;
|
||||
use crate::{modules::error::BichonResult, raise_error};
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::imap::session::SessionStream;
|
||||
use crate::{error::BichonResult, raise_error};
|
||||
use async_imap::types::Capability;
|
||||
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
|
||||
//
|
||||
@@ -16,14 +16,14 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::account::entity::Encryption;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::imap::session::SessionStream;
|
||||
use crate::modules::imap::stats::StatsWrapper;
|
||||
use crate::modules::utils::net::establish_tcp_connection_with_timeout;
|
||||
use crate::modules::utils::net::establish_tls_connection;
|
||||
use crate::modules::utils::tls::establish_tls_stream;
|
||||
use crate::account::entity::Encryption;
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::error::BichonResult;
|
||||
use crate::imap::session::SessionStream;
|
||||
use crate::imap::stats::StatsWrapper;
|
||||
use crate::utils::net::establish_tcp_connection_with_timeout;
|
||||
use crate::utils::net::establish_tls_connection;
|
||||
use crate::utils::tls::establish_tls_stream;
|
||||
use crate::raise_error;
|
||||
use async_imap::Client as ImapClient;
|
||||
use async_imap::Session as ImapSession;
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,38 +16,23 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::account::dispatcher::STATUS_DISPATCHER;
|
||||
use crate::modules::account::entity::AuthType;
|
||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::imap::capabilities::{
|
||||
capability_to_string, check_capabilities, fetch_capabilities,
|
||||
};
|
||||
use crate::modules::imap::client::Client;
|
||||
use crate::modules::imap::oauth2::OAuth2;
|
||||
use crate::modules::imap::session::SessionStream;
|
||||
use crate::modules::oauth2::token::OAuth2AccessToken;
|
||||
use crate::account::entity::AuthType;
|
||||
use crate::account::migration::{AccountModel, AccountType};
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::error::BichonResult;
|
||||
use crate::imap::capabilities::{capability_to_string, check_capabilities, fetch_capabilities};
|
||||
use crate::imap::client::Client;
|
||||
use crate::imap::oauth2::OAuth2;
|
||||
use crate::imap::session::SessionStream;
|
||||
use crate::oauth2::token::OAuth2AccessToken;
|
||||
use crate::{bichon_version, decrypt, raise_error};
|
||||
use async_imap::Session;
|
||||
use tracing::error;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImapConnectionManager {
|
||||
pub account_id: u64,
|
||||
}
|
||||
pub struct ImapConnectionManager;
|
||||
|
||||
impl ImapConnectionManager {
|
||||
pub fn new(account_id: u64) -> Self {
|
||||
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> {
|
||||
async fn create_client(account: &AccountModel) -> BichonResult<Client> {
|
||||
assert_eq!(account.account_type, AccountType::IMAP);
|
||||
let imap = account.imap.as_ref().unwrap();
|
||||
Client::connection(
|
||||
@@ -61,13 +46,12 @@ impl ImapConnectionManager {
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
&self,
|
||||
client: Client,
|
||||
account: &AccountModel,
|
||||
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
assert_eq!(account.account_type, AccountType::IMAP);
|
||||
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 {
|
||||
AuthType::Password => {
|
||||
let password = &imap.auth.password.clone().ok_or_else(|| {
|
||||
@@ -78,10 +62,16 @@ impl ImapConnectionManager {
|
||||
})?;
|
||||
|
||||
let password = decrypt!(&password)?;
|
||||
client.login(&username, &password).await
|
||||
client.login(&login_name, &password).await.map_err(|e| {
|
||||
error!(
|
||||
"IMAP password auth failed for username '{}': {}",
|
||||
login_name, e
|
||||
);
|
||||
e
|
||||
})
|
||||
}
|
||||
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(|| {
|
||||
raise_error!(
|
||||
"Imap auth type is OAuth2, but OAuth2 authorization is not yet complete."
|
||||
@@ -90,42 +80,36 @@ impl ImapConnectionManager {
|
||||
)
|
||||
})?;
|
||||
client
|
||||
.authenticate(OAuth2::new(username, access_token))
|
||||
.authenticate(OAuth2::new(login_name.clone(), access_token))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"IMAP OAuth2 auth failed for username '{}': {}",
|
||||
login_name, e
|
||||
);
|
||||
e
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build(&self) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
let account = self.fetch_account().await?;
|
||||
let client = match self.create_client(&account).await {
|
||||
pub async fn build(account_id: u64) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
let account = AccountModel::get(account_id)?;
|
||||
let client = match Self::create_client(&account).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
error!(
|
||||
"Failed to create IMAP {}'s client: {:#?}",
|
||||
&account.email, error
|
||||
);
|
||||
STATUS_DISPATCHER
|
||||
.append_error(
|
||||
self.account_id,
|
||||
format!("imap client connect error: {:#?}", error),
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let mut session = match self.authenticate(client, &account).await {
|
||||
let mut session = match Self::authenticate(client, &account).await {
|
||||
Ok(session) => session,
|
||||
Err(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);
|
||||
}
|
||||
};
|
||||
@@ -133,39 +117,27 @@ impl ImapConnectionManager {
|
||||
match fetch_capabilities(&mut session).await {
|
||||
Ok(capabilities) => {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
if capabilities.has_str("ID") || capabilities.has_str("id") {
|
||||
session
|
||||
if let Err(e) = session
|
||||
.id([
|
||||
("name", Some("bichon")),
|
||||
("version", Some(bichon_version!())),
|
||||
("vendor", Some("rustmailer")),
|
||||
])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
{
|
||||
warn!("IMAP ID command failed (ignored): {:#?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -22,7 +22,6 @@ pub mod client;
|
||||
pub mod executor;
|
||||
pub mod manager;
|
||||
pub mod oauth2;
|
||||
pub mod pool;
|
||||
pub mod session;
|
||||
pub mod stats;
|
||||
#[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
|
||||
//
|
||||
@@ -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
|
||||
//
|
||||
@@ -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
|
||||
//
|
||||
@@ -23,7 +23,7 @@ use std::task::{Context, Poll};
|
||||
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
|
||||
use crate::modules::imap::session::SessionStream;
|
||||
use crate::imap::session::SessionStream;
|
||||
|
||||
pub struct StatsWrapper<T> {
|
||||
inner: T,
|
||||
@@ -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,39 @@
|
||||
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 tantivy::doc;
|
||||
|
||||
use crate::{
|
||||
base64_decode_url_safe,
|
||||
modules::{
|
||||
{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
|
||||
envelope::extractor::extract_envelope_from_eml,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
indexer::{
|
||||
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
schema::SchemaTools,
|
||||
},
|
||||
error::{BichonResult, code::ErrorCode},
|
||||
utils::create_hash,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
#[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 BatchEmlRequest {
|
||||
pub account_id: u64,
|
||||
pub mail_folder: String,
|
||||
@@ -26,7 +41,8 @@ pub struct BatchEmlRequest {
|
||||
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 {
|
||||
/// The 0-based index of the failed EML in the request list
|
||||
pub index: usize,
|
||||
@@ -34,7 +50,8 @@ pub struct FailedEmlDetail {
|
||||
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 {
|
||||
/// Total number of emails processed
|
||||
pub total: usize,
|
||||
@@ -50,7 +67,7 @@ pub struct ImportEmls;
|
||||
|
||||
impl ImportEmls {
|
||||
pub async fn do_import(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 {
|
||||
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
|
||||
@@ -58,7 +75,7 @@ impl ImportEmls {
|
||||
|
||||
let mailbox_id = match account.account_type {
|
||||
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);
|
||||
|
||||
match mailbox {
|
||||
@@ -88,19 +105,17 @@ impl ImportEmls {
|
||||
};
|
||||
let mailbox_id = mailbox.id;
|
||||
// Upsert the mailbox, creating it if it doesn't exist
|
||||
MailBox::batch_upsert(&[mailbox]).await?;
|
||||
MailBox::batch_upsert(&[mailbox])?;
|
||||
mailbox_id
|
||||
},
|
||||
};
|
||||
|
||||
let fields = SchemaTools::eml_fields();
|
||||
let account_id = account.id;
|
||||
let mut success_count = 0;
|
||||
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
|
||||
|
||||
let total = request.emls.len();
|
||||
for (index, eml_base64) in request.emls.into_iter().enumerate() {
|
||||
// 1. Decode Base64
|
||||
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
@@ -115,8 +130,10 @@ impl ImportEmls {
|
||||
}
|
||||
};
|
||||
|
||||
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
|
||||
Ok(env) => env,
|
||||
match extract_envelope_from_eml(&decoded, account_id, mailbox_id).await {
|
||||
Ok(_) => {
|
||||
success_count += 1;
|
||||
},
|
||||
Err(e) => {
|
||||
let error_msg = format!(
|
||||
"Failed to extract envelope from EML at index {}: {:?}",
|
||||
@@ -130,24 +147,6 @@ impl ImportEmls {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,10 +16,9 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::logger::{validate_log_level, LocalTimer};
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::modules::settings::dir::DATA_DIR_MANAGER;
|
||||
use crate::logger::LocalTimer;
|
||||
use crate::settings::cli::SETTINGS;
|
||||
use crate::settings::dir::DATA_DIR_MANAGER;
|
||||
use std::sync::OnceLock;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing::Level;
|
||||
@@ -30,9 +29,7 @@ use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
pub static LOG_WORKER_GUARD: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
|
||||
|
||||
pub fn setup_file_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||
validate_log_level(&SETTINGS.bichon_log_level);
|
||||
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
|
||||
pub fn setup_file_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||
let with_ansi = SETTINGS.bichon_ansi_logs;
|
||||
|
||||
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
|
||||
//
|
||||
@@ -16,12 +16,12 @@
|
||||
// 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::logger::file::setup_file_logger;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::settings::cli::SETTINGS;
|
||||
use chrono::Local;
|
||||
use std::process;
|
||||
use tracing::Level;
|
||||
use tracing_log::LogTracer;
|
||||
use tracing_subscriber::fmt::{format::Writer, time::FormatTime};
|
||||
|
||||
mod file;
|
||||
@@ -35,16 +35,18 @@ impl FormatTime for LocalTimer {
|
||||
}
|
||||
|
||||
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 {
|
||||
setup_file_logger().unwrap();
|
||||
setup_file_logger(level).unwrap();
|
||||
} else {
|
||||
setup_stdout_logger().unwrap();
|
||||
setup_stdout_logger(level).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||
validate_log_level(&SETTINGS.bichon_log_level);
|
||||
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
|
||||
fn setup_stdout_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||
let with_ansi = SETTINGS.bichon_ansi_logs;
|
||||
|
||||
let format = tracing_subscriber::fmt::format()
|
||||
@@ -62,13 +64,16 @@ fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultErro
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
}
|
||||
|
||||
fn validate_log_level(value: &String) {
|
||||
if value.parse::<Level>().is_err() {
|
||||
eprintln!(
|
||||
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
|
||||
The log level you currently specified is 'rustmailer_log_level'='{}'",
|
||||
value
|
||||
);
|
||||
process::exit(1);
|
||||
fn validate_log_level(value: &String) -> Level {
|
||||
match value.parse::<Level>() {
|
||||
Ok(level) => level,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
|
||||
The log level you currently specified is 'rustmailer_log_level'='{}'",
|
||||
value
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,5 +16,5 @@
|
||||
// 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 delete;
|
||||
pub mod list;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
//
|
||||
@@ -16,16 +16,16 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
|
||||
use crate::error::BichonResult;
|
||||
use crate::store::tantivy::attachment::ATTACHMENT_MANAGER;
|
||||
use crate::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub async fn delete_messages_impl(request: HashMap<u64, Vec<u64>>) -> BichonResult<()> {
|
||||
EML_INDEX_MANAGER
|
||||
.delete_email_multi_account(&request)
|
||||
pub async fn delete_messages_impl(request: HashMap<u64, Vec<String>>) -> BichonResult<()> {
|
||||
ENVELOPE_MANAGER
|
||||
.delete_envelopes_multi_account(request.clone())
|
||||
.await?;
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.delete_envelopes_multi_account(&request)
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_envelopes_multi_account(request)
|
||||
.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
|
||||
//
|
||||
@@ -16,9 +16,19 @@
|
||||
// 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},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test() {
|
||||
let config = autoconfig::from_addr("test@gmail.com").await.unwrap();
|
||||
println!("{:#?}", config);
|
||||
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
|
||||
//
|
||||
@@ -16,7 +16,9 @@
|
||||
// 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 append;
|
||||
pub mod attachment;
|
||||
pub mod contacts;
|
||||
pub mod content;
|
||||
pub mod delete;
|
||||
pub mod list;
|
||||
@@ -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