mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769630f9d7 | ||
|
|
368b18c45f | ||
|
|
42861f6cc9 | ||
|
|
327a3f39d9 | ||
|
|
427f7248d2 | ||
|
|
a2a51a2037 | ||
|
|
4d783d5301 | ||
|
|
4172f11f00 | ||
|
|
257736a47b | ||
|
|
e8469da3bc | ||
|
|
1346dd216a | ||
|
|
d40ba90b54 | ||
|
|
048d5f361c | ||
|
|
4597df515a | ||
|
|
f4be4a2e8c | ||
|
|
4a3c42c1eb | ||
|
|
8fcb55320f | ||
|
|
86869ac848 | ||
|
|
38453accc6 | ||
|
|
a60b2c7dc4 | ||
|
|
8e46c7a162 | ||
|
|
1a615e1c45 | ||
|
|
83dd9cdd6b | ||
|
|
f30cd66e00 | ||
|
|
4bd714a670 | ||
|
|
c0a63a1e3c | ||
|
|
04a022e850 | ||
|
|
f9c2fc77ff | ||
|
|
ec3e842bbb | ||
|
|
7311529908 | ||
|
|
0792bb546d | ||
|
|
0be2670600 | ||
|
|
575f851cfb | ||
|
|
61430b72b0 | ||
|
|
15c0cfc1d9 | ||
|
|
ea8c493374 | ||
|
|
005b1c2116 | ||
|
|
d8b78b8010 | ||
|
|
36f3f19cdc | ||
|
|
9f3097df32 | ||
|
|
c075b9ef12 | ||
|
|
21a7f7e9d5 | ||
|
|
26c14fcaaf | ||
|
|
6873841ba4 | ||
|
|
0dd81f599d | ||
|
|
b3afc52a82 | ||
|
|
171a40d70f | ||
|
|
895ea543a9 | ||
|
|
6eca351994 | ||
|
|
1f477eca65 | ||
|
|
a3cdc094e8 | ||
|
|
95147a7824 | ||
|
|
b22811f78c | ||
|
|
fd61d013a2 | ||
|
|
3a950e7591 | ||
|
|
f17820bfa8 | ||
|
|
178b25d27d | ||
|
|
d160ca75f5 | ||
|
|
04136a4ae2 | ||
|
|
1d6f5d9a22 | ||
|
|
105a6d9b15 | ||
|
|
df440c8441 | ||
|
|
4116a59b79 | ||
|
|
609eee1b84 | ||
|
|
79b9f07888 | ||
|
|
ba28369202 | ||
|
|
ff64b66f79 | ||
|
|
a4f8e674c3 | ||
|
|
dde6b990da | ||
|
|
6b1f843bd5 | ||
|
|
66fd50bc23 | ||
|
|
9daab241b0 | ||
|
|
85d5490834 | ||
|
|
6e984f376c | ||
|
|
7470125a23 | ||
|
|
469d254e2b | ||
|
|
7fde7ee19a | ||
|
|
d543508a23 | ||
|
|
f440069912 | ||
|
|
9f9fc71d16 | ||
|
|
b2a75643da | ||
|
|
37a38a2910 | ||
|
|
8817ed96f6 | ||
|
|
1ee2eade3a | ||
|
|
7afb1e29aa | ||
|
|
715858183d | ||
|
|
407d865a9a | ||
|
|
e708dc524b | ||
|
|
55996f8f9b | ||
|
|
8bb39b4095 | ||
|
|
0a7a22fa7f | ||
|
|
3fa4fe453c | ||
|
|
907e59027d | ||
|
|
3ea330c884 | ||
|
|
d52c5eef3e | ||
|
|
6b6f11e4c1 | ||
|
|
1682f21cf7 | ||
|
|
5406c4322c | ||
|
|
0abaa66a40 | ||
|
|
123260b69d | ||
|
|
c086a3caf5 | ||
|
|
c07e39495a | ||
|
|
41322c8357 | ||
|
|
2f8ba5ad40 | ||
|
|
213c6452a8 | ||
|
|
9c91025c53 | ||
|
|
be934e2b3f | ||
|
|
12e7bb6ba3 | ||
|
|
f66a86392d | ||
|
|
66b595908c | ||
|
|
174d56e7b4 | ||
|
|
7eacfbfb20 | ||
|
|
2802c7ea07 | ||
|
|
f583c3413c | ||
|
|
9bacf3fb7a | ||
|
|
e2fb0ee39e | ||
|
|
1be5fea51a | ||
|
|
2d29a8111b | ||
|
|
0c2e540834 | ||
|
|
e83a00fe39 | ||
|
|
fa70437a62 | ||
|
|
0b866c81ff | ||
|
|
5b884125f7 | ||
|
|
c3a12eafb2 | ||
|
|
c14834abe8 | ||
|
|
51329fb2e1 | ||
|
|
5ad940269f | ||
|
|
f9ceb83293 | ||
|
|
c185ea102c | ||
|
|
da79916396 | ||
|
|
b29c6ea9ff | ||
|
|
7dd722f9b8 | ||
|
|
19b5168960 | ||
|
|
d90943bbfe | ||
|
|
18a0d52c57 | ||
|
|
15dd26228c | ||
|
|
39d8168de5 | ||
|
|
82915e76ba | ||
|
|
6d5953c73b | ||
|
|
61161b5f3b | ||
|
|
286ae16057 | ||
|
|
8b4dc44c07 | ||
|
|
64a66b4f98 | ||
|
|
5a50f5e327 | ||
|
|
bd10e15c65 | ||
|
|
c123ecb24a | ||
|
|
01182dc90d | ||
|
|
7626634863 | ||
|
|
0914bf710e | ||
|
|
3f11c5dbbf | ||
|
|
5d0039cb74 | ||
|
|
a41b5417e3 | ||
|
|
c19f3977ba | ||
|
|
884fdeba10 | ||
|
|
2228e98410 | ||
|
|
d690f57290 | ||
|
|
8a42fcdb4a | ||
|
|
5028061f20 | ||
|
|
a8b3b24d59 | ||
|
|
af0f47c0e3 | ||
|
|
2b10d201ee | ||
|
|
638a93f184 | ||
|
|
40eca89a75 | ||
|
|
f3c46f97b9 | ||
|
|
396383aa97 | ||
|
|
8f331080bf | ||
|
|
4b0d571cf2 | ||
|
|
16f0fad91e | ||
|
|
dda6d77046 | ||
|
|
a273b7f5e1 | ||
|
|
2cba001431 | ||
|
|
54ed2c3c0a | ||
|
|
5e3d0f1c06 | ||
|
|
a9a9b4a85f | ||
|
|
a5ea98f731 | ||
|
|
21c3d2b795 | ||
|
|
c15fe2a503 | ||
|
|
5f013ea173 | ||
|
|
393b7361e8 | ||
|
|
fd35f4be8e | ||
|
|
d2936ed4a7 | ||
|
|
ef4ab3496e | ||
|
|
2295585deb | ||
|
|
57afa30b5b | ||
|
|
ba8ecdd899 | ||
|
|
673e593c4f | ||
|
|
579822762f | ||
|
|
d63b1e0d7c | ||
|
|
a0d8d069c0 | ||
|
|
bdbbc04832 | ||
|
|
fed28c3eca | ||
|
|
01dba4f71b |
@@ -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,8 +5,8 @@ on:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
env:
|
||||
BINARY_NAME: bichon
|
||||
BINARY_CTL: bichonctl
|
||||
BINARY_NAME: bichon-server
|
||||
BINARY_CLI: bichon-cli
|
||||
BINARY_ADMIN: bichon-admin
|
||||
|
||||
permissions:
|
||||
@@ -82,18 +82,18 @@ 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_CTL }}
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_CLI }}
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}
|
||||
|
||||
- name: Pack artifact (Linux/macOS)
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
BINARY="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
|
||||
cp README.md LICENSE release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_CTL }} release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_CLI }} release/
|
||||
cp target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }} release/
|
||||
tar -czvf "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" -C release .
|
||||
mv "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" release/
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
mkdir -p release
|
||||
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" release/
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}.exe" release/
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CLI }}.exe" release/
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}.exe" release/
|
||||
|
||||
Copy-Item -Path README.md -Destination release/
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/target
|
||||
.vscode
|
||||
.idea
|
||||
config.toml
|
||||
node_modules
|
||||
dedup_report.txt
|
||||
Generated
+1667
-2004
File diff suppressed because it is too large
Load Diff
+56
-89
@@ -1,57 +1,34 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.3.6"
|
||||
[workspace]
|
||||
|
||||
members = [
|
||||
"crates/memdb",
|
||||
"crates/core",
|
||||
"crates/server",
|
||||
"crates/cli",
|
||||
"crates/admin",
|
||||
"crates/smtp",
|
||||
]
|
||||
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.5.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "bichon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bichonctl"
|
||||
path = "src/bin/bichonctl.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bichon-admin"
|
||||
path = "src/bin/bichon_admin.rs"
|
||||
|
||||
|
||||
[features]
|
||||
default = []
|
||||
vendored-openssl = ["openssl-sys"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
opt-level = 3
|
||||
codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.43"
|
||||
clap = { version = "4.5.54", 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"] }
|
||||
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.149"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
serde_json = "1.0.150"
|
||||
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",
|
||||
@@ -61,70 +38,60 @@ reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
"socks",
|
||||
] }
|
||||
tokio-socks = "0.5.2"
|
||||
http = "1.4.0"
|
||||
regex = "1.12.2"
|
||||
http = "1.4.1"
|
||||
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.8", features = ["serde"] }
|
||||
sysinfo = "0.37.2"
|
||||
sysinfo = "0.39.2"
|
||||
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.5"
|
||||
rustls = { version = "0.23.36", default-features = false, features = ["ring"] }
|
||||
rustls-pki-types = "1.14.0"
|
||||
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"
|
||||
semver = "1.0.28"
|
||||
governor = "0.10.4"
|
||||
lru = "0.16.3"
|
||||
lru = "0.18.0"
|
||||
mime_guess = "2.0.5"
|
||||
hex = "0.4.3"
|
||||
time = { version = "0.3.45", features = [
|
||||
time = { version = "0.3.47", features = [
|
||||
"formatting",
|
||||
"parsing",
|
||||
"local-offset",
|
||||
] }
|
||||
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"] }
|
||||
dashmap = "6.2.1"
|
||||
gethostname = "1.1.0"
|
||||
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
|
||||
itoa = "1.0.17"
|
||||
html2text = "0.16.6"
|
||||
bytes = "1.11.0"
|
||||
itoa = "1.0.18"
|
||||
html2text = "0.17.1"
|
||||
bytes = "1.11.1"
|
||||
dialoguer = "0.12.0"
|
||||
console = "0.16.2"
|
||||
toml = "0.9.8"
|
||||
memmap2 = "0.9.9"
|
||||
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
|
||||
compressed-rtf = "1.0.0"
|
||||
codepage-strings = "1.0.2"
|
||||
mail-send = "0.5.2"
|
||||
[dev-dependencies]
|
||||
#bincode = "1.3.3"
|
||||
#secret-lib = "1.0.0"
|
||||
tempfile = "3.24.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
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
base_url = "http://localhost:15630"
|
||||
api_token = "lZHmfpH1CRr9XsRiOGd1RnOr"
|
||||
api_token = "eErI7WN3PtKeLwWAbIfSXCP6"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[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
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// 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 crate::{migrate::handle_migration, reset::handle_reset_password};
|
||||
|
||||
pub mod meta;
|
||||
pub mod migrate;
|
||||
pub mod reset;
|
||||
|
||||
|
||||
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.x",
|
||||
"Exit",
|
||||
];
|
||||
|
||||
let selection = Select::with_theme(&theme)
|
||||
.with_prompt("Select an operation")
|
||||
.default(0)
|
||||
.items(&main_options)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
match selection {
|
||||
0 => handle_reset_password(&theme),
|
||||
1 => handle_migration(&theme),
|
||||
_ => {
|
||||
println!("{}", style("Exiting...").dim());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
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,
|
||||
download_folders: value.sync_folders,
|
||||
account_type: value.account_type,
|
||||
download_interval_min: value.sync_interval_min,
|
||||
download_batch_size: value.sync_batch_size,
|
||||
max_email_size_bytes: None,
|
||||
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,
|
||||
download_schedule: 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,
|
||||
highest_uid: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,449 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bichon_core::migrate::{
|
||||
count_eml_segments, do_migrate_segment, is_tantivy_index_dir,
|
||||
store::{LegacyDirs, NewDirs, NewIndexWriter},
|
||||
};
|
||||
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.x")
|
||||
.bold()
|
||||
.yellow()
|
||||
);
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style(
|
||||
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
|
||||
architecture to the new v1.x \
|
||||
separated index and Fjall-backed storage format."
|
||||
)
|
||||
.dim()
|
||||
);
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style(
|
||||
"Legacy v0.3.7 architecture:\n\
|
||||
• envelope metadata stored in Tantivy\n\
|
||||
• message data stored in Tantivy\n\n\
|
||||
New v1.x architecture:\n\
|
||||
• mail indexes stored in Tantivy\n\
|
||||
• attachment indexes stored in Tantivy\n\
|
||||
• raw message data stored in Fjall\n\
|
||||
• attachment blobs stored in Fjall"
|
||||
)
|
||||
.dim()
|
||||
);
|
||||
|
||||
println!(
|
||||
"\n{} {}",
|
||||
style("IMPORTANT:").yellow().bold(),
|
||||
style(
|
||||
"The paths below must exactly match what your old bichon server was configured with."
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
|
||||
// --- bichon-root-dir ---
|
||||
let root_dir_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter --bichon-root-dir (same value used by the old server)")
|
||||
.validate_with(|input: &String| -> Result<(), &str> {
|
||||
let path = Path::new(input);
|
||||
if !path.is_absolute() {
|
||||
return Err("Path must be absolute.");
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err("Directory does not exist.");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let root_path = PathBuf::from(&root_dir_str);
|
||||
|
||||
// --- bichon-index-dir ---
|
||||
let default_index = root_path.join("envelope");
|
||||
let default_new_index = root_path.join("bichon-indices");
|
||||
let index_dir_str: String = Input::with_theme(theme)
|
||||
.with_prompt(format!(
|
||||
"Enter --bichon-index-dir (leave blank to use default: {})",
|
||||
style(default_index.display()).cyan()
|
||||
))
|
||||
.allow_empty(true)
|
||||
.validate_with(|input: &String| -> Result<(), &str> {
|
||||
if input.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = Path::new(input);
|
||||
if !path.is_absolute() {
|
||||
return Err("Path must be absolute.");
|
||||
}
|
||||
|
||||
if !path.exists() {
|
||||
return Err("Directory does not exist.");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let index_path = if index_dir_str.is_empty() {
|
||||
default_index
|
||||
} else {
|
||||
PathBuf::from(&index_dir_str)
|
||||
};
|
||||
|
||||
let new_index_path = if index_dir_str.is_empty() {
|
||||
default_new_index
|
||||
} else {
|
||||
PathBuf::from(&index_dir_str).join("bichon-indices")
|
||||
};
|
||||
|
||||
// --- bichon-data-dir ---
|
||||
let default_data = root_path.join("eml");
|
||||
let default_new_data = root_path.join("bichon-storage");
|
||||
let data_dir_str: String = Input::with_theme(theme)
|
||||
.with_prompt(format!(
|
||||
"Enter --bichon-data-dir (leave blank to use default: {})",
|
||||
style(default_data.display()).cyan()
|
||||
))
|
||||
.allow_empty(true)
|
||||
.validate_with(|input: &String| -> Result<(), &str> {
|
||||
if input.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = Path::new(input);
|
||||
if !path.is_absolute() {
|
||||
return Err("Path must be absolute.");
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err("Directory does not exist.");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let data_path = if data_dir_str.is_empty() {
|
||||
default_data
|
||||
} else {
|
||||
PathBuf::from(&data_dir_str)
|
||||
};
|
||||
|
||||
let new_data_path = if data_dir_str.is_empty() {
|
||||
default_new_data
|
||||
} else {
|
||||
PathBuf::from(&data_dir_str).join("bichon-storage")
|
||||
};
|
||||
|
||||
println!("\n{}", style("Paths to be migrated:").bold());
|
||||
println!("----------------------------------------");
|
||||
println!(
|
||||
"{:<20} : {}",
|
||||
"bichon-root-dir",
|
||||
style(root_path.display()).cyan()
|
||||
);
|
||||
println!(
|
||||
"{:<20} : {}",
|
||||
"bichon-index-dir",
|
||||
style(index_path.display()).cyan()
|
||||
);
|
||||
println!(
|
||||
"{:<20} : {}",
|
||||
"bichon-data-dir",
|
||||
style(data_path.display()).cyan()
|
||||
);
|
||||
println!("----------------------------------------");
|
||||
|
||||
println!(
|
||||
"\n{} Checking legacy v0.3.7 storage layout...",
|
||||
style("⌛").yellow()
|
||||
);
|
||||
|
||||
match is_legacy_data_layout_with_paths(&index_path, &data_path) {
|
||||
Ok(true) => {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("✔").green(),
|
||||
style("Legacy v0.3.7 Tantivy-based storage detected. Migration to v1.x is required.")
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
Ok(false) => {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("✔").green(),
|
||||
style("No legacy v0.3.7 storage layout was detected at the specified paths.").green()
|
||||
);
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style(
|
||||
"The selected directories may already be using the v1.x 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()
|
||||
);
|
||||
|
||||
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 writer = match NewIndexWriter::open(NewDirs::new(
|
||||
new_index_path.clone(),
|
||||
new_data_path.clone(),
|
||||
)) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
pb.finish_with_message(format!("{}", style("Migration failed.").red()));
|
||||
eprintln!("\n{} {:?}", style("✘").red().bold(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
&mut writer,
|
||||
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.set_message(style("Finalizing indexes...").dim().to_string());
|
||||
if let Err(e) = writer.finish_writers() {
|
||||
pb.finish_with_message(format!("{}", style("Migration failed.").red()));
|
||||
eprintln!("\n{} {:?}", style("✘").red().bold(), e);
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,41 +1,14 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bichon::modules::{
|
||||
cli::admin::meta::{find_admin, init_meta_database, update_admin_password},
|
||||
error::BichonError,
|
||||
use bichon_core::{
|
||||
admin::meta::{find_admin, open_database, update_admin_password},
|
||||
utils::encrypt::internal_decrypt_string,
|
||||
};
|
||||
use console::{style, Emoji};
|
||||
use dialoguer::Confirm;
|
||||
use dialoguer::{theme::ColorfulTheme, Input, Password, Select};
|
||||
use native_db::Database;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let theme = ColorfulTheme::default();
|
||||
println!(
|
||||
"\n{}\n",
|
||||
style("BICHON ADMINISTRATIVE TOOL").bold().bright().cyan()
|
||||
);
|
||||
|
||||
let main_options = vec!["Reset Admin Password", "Exit"];
|
||||
let selection = Select::with_theme(&theme)
|
||||
.with_prompt("Select an operation")
|
||||
.default(0)
|
||||
.items(&main_options)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
if selection == 1 {
|
||||
println!("{}", style("Exiting...").dim());
|
||||
return;
|
||||
}
|
||||
|
||||
let root_dir_str: String = Input::with_theme(&theme)
|
||||
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);
|
||||
@@ -45,9 +18,9 @@ async fn main() {
|
||||
if !path.exists() {
|
||||
return Err("Directory does not exist.");
|
||||
}
|
||||
let has_metadata = path.join("meta.db").exists();
|
||||
if !has_metadata {
|
||||
return Err("Invalid directory: 'meta.db' not found.");
|
||||
let memdb_dir = path.join("memdb");
|
||||
if !memdb_dir.exists() || !memdb_dir.is_dir() {
|
||||
return Err("Invalid directory: 'memdb' data directory not found.");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -55,40 +28,14 @@ async fn main() {
|
||||
.unwrap();
|
||||
|
||||
let root_path = PathBuf::from(&root_dir_str);
|
||||
|
||||
let database: Rc<Database<'static>> = match init_meta_database(&root_path.join("meta.db")) {
|
||||
Ok(database) => database,
|
||||
Err(e) => match e {
|
||||
BichonError::Generic {
|
||||
message,
|
||||
location,
|
||||
code,
|
||||
} => {
|
||||
if message.contains("RedbDatabaseError(DatabaseAlreadyOpen") {
|
||||
println!("\n{}", style("ERROR: Database is locked.").red().bold());
|
||||
println!(
|
||||
"{}",
|
||||
style("The Bichon service is likely still running.").yellow()
|
||||
);
|
||||
println!(
|
||||
"Since the database cannot be shared between multiple instances, \n\
|
||||
you must {} the Bichon service before proceeding.",
|
||||
style("STOP").underlined().bold()
|
||||
);
|
||||
std::process::exit(1);
|
||||
} else {
|
||||
eprintln!(
|
||||
"\n{} (Code: {:#?})\nLocation: {}\nMessage: {}",
|
||||
style("A database error occurred:").red().bold(),
|
||||
code,
|
||||
location,
|
||||
message
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -124,24 +71,24 @@ async fn main() {
|
||||
"Enter encryption password manually",
|
||||
"Read from password file",
|
||||
];
|
||||
let method = Select::with_theme(&theme)
|
||||
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 {
|
||||
Password::with_theme(&theme)
|
||||
Input::with_theme(theme)
|
||||
.with_prompt("Enter Encryption Password")
|
||||
.interact()
|
||||
.unwrap()
|
||||
} else {
|
||||
let file_path: String = Input::with_theme(&theme)
|
||||
let file_path: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter path to encryption password file")
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
match fs::read_to_string(&file_path) {
|
||||
match std::fs::read_to_string(&file_path) {
|
||||
Ok(content) => content.trim().to_string(),
|
||||
Err(e) => {
|
||||
println!("{}: {}", style("Failed to read file").red(), e);
|
||||
@@ -170,7 +117,7 @@ async fn main() {
|
||||
style("BICHON_ENCRYPT_PASSWORD_FILE").green()
|
||||
);
|
||||
|
||||
if Confirm::with_theme(&theme)
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt(prompt_message)
|
||||
.default(true)
|
||||
.interact()
|
||||
@@ -216,7 +163,7 @@ async fn main() {
|
||||
println!("{:<12} : {}", "Password", pwd_display);
|
||||
println!("----------------------------------------");
|
||||
|
||||
if !dialoguer::Confirm::with_theme(&theme)
|
||||
if !dialoguer::Confirm::with_theme(theme)
|
||||
.with_prompt(format!(
|
||||
"Do you want to reset the password for '{}'?",
|
||||
user.username
|
||||
@@ -255,13 +202,13 @@ async fn main() {
|
||||
.bold()
|
||||
);
|
||||
|
||||
let new_login_password = Password::with_theme(&theme)
|
||||
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)
|
||||
if !Confirm::with_theme(theme)
|
||||
.with_prompt("Proceed with database update?")
|
||||
.interact()
|
||||
.unwrap()
|
||||
@@ -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,58 @@
|
||||
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,
|
||||
account_ids: Option<std::collections::HashSet<u64>>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> Option<DataPage<Envelope>> {
|
||||
let url = format!("{}/api/v1/search-messages", config.base_url);
|
||||
|
||||
let payload = EmailSearchRequest {
|
||||
filter: EmailSearchFilter {
|
||||
account_ids,
|
||||
..Default::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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,43 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::process;
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Select};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::modules::{
|
||||
use bichon_core::{
|
||||
account::payload::MinimalAccount,
|
||||
cli::BichonCtlConfig,
|
||||
users::{permissions::Permission, view::UserView},
|
||||
};
|
||||
|
||||
pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &ColorfulTheme) -> u64 {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/v1/current-user", config.base_url);
|
||||
use crate::BichonCliConfig;
|
||||
|
||||
async fn fetch_json<T: serde::de::DeserializeOwned>(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
label: &str,
|
||||
) -> T {
|
||||
let response = match client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
@@ -36,19 +57,15 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "No error detail provided".to_string());
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
|
||||
if !status.is_success() {
|
||||
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.",
|
||||
@@ -60,36 +77,66 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
||||
style("Context:").dim()
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("{} {}", style("Response:").dim(), error_body);
|
||||
eprintln!("{} {}", style("Response:").dim(), body);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let user: UserView = response.json().await.expect("Failed to parse user data");
|
||||
println!("Welcome, {}!", style(&user.username).cyan());
|
||||
|
||||
let account_list_url = format!(
|
||||
"{}/api/v1/minimal-account-list?only_nosync=true",
|
||||
config.base_url
|
||||
);
|
||||
let acc_response = client
|
||||
.get(&account_list_url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to fetch account list");
|
||||
|
||||
if !acc_response.status().is_success() {
|
||||
panic!(
|
||||
"Failed to retrieve accounts. Status: {}",
|
||||
acc_response.status()
|
||||
if body.is_empty() {
|
||||
eprintln!(
|
||||
"\n{} Server returned an empty response for [{}] (Status: {})",
|
||||
style("✘ Empty Response:").red().bold(),
|
||||
label,
|
||||
status
|
||||
);
|
||||
eprintln!(
|
||||
"{} This may be caused by a reverse proxy or middleware issue.",
|
||||
style("Tip:").cyan()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let accounts: Vec<MinimalAccount> = acc_response
|
||||
.json()
|
||||
.await
|
||||
.expect("Failed to parse minimal account list");
|
||||
match serde_json::from_str::<T>(&body) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"\n{} Failed to parse response for [{}]: {}",
|
||||
style("✘ Parse Error:").red().bold(),
|
||||
label,
|
||||
e
|
||||
);
|
||||
eprintln!("{} Raw body: {}", style("Debug:").dim(), body);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_user_and_get_account(
|
||||
config: &BichonCliConfig,
|
||||
theme: &ColorfulTheme,
|
||||
only_nosync: bool,
|
||||
) -> MinimalAccount {
|
||||
let client = Client::new();
|
||||
|
||||
let user: UserView = fetch_json(
|
||||
&client,
|
||||
&format!("{}/api/v1/current-user", config.base_url),
|
||||
&config.api_token,
|
||||
"current-user",
|
||||
)
|
||||
.await;
|
||||
|
||||
println!("Welcome, {}!", style(&user.username).cyan());
|
||||
|
||||
let accounts: Vec<MinimalAccount> = fetch_json(
|
||||
&client,
|
||||
&format!(
|
||||
"{}/api/v1/minimal-account-list?only_nosync={only_nosync}",
|
||||
config.base_url
|
||||
),
|
||||
&config.api_token,
|
||||
"minimal-account-list",
|
||||
)
|
||||
.await;
|
||||
|
||||
if accounts.is_empty() {
|
||||
println!(
|
||||
@@ -106,6 +153,7 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let required_permission = Permission::DATA_IMPORT_BATCH;
|
||||
let mut selectable_accounts = Vec::new();
|
||||
let mut options = Vec::new();
|
||||
@@ -165,5 +213,5 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
||||
style(&selected_acc.email).cyan().bold()
|
||||
);
|
||||
|
||||
selected_acc.id
|
||||
selected_acc.clone()
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
@@ -9,13 +27,12 @@ use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use mail_parser::MessageParser;
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::{
|
||||
base64_encode_url_safe,
|
||||
modules::cli::{sender::send_batch_request, BichonCtlConfig},
|
||||
};
|
||||
use bichon_core::base64_encode_url_safe;
|
||||
|
||||
use crate::{BichonCliConfig, api::sender::send_batch_request};
|
||||
|
||||
pub async fn handle_eml_directory_import(
|
||||
config: &BichonCtlConfig,
|
||||
config: &BichonCliConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
@@ -83,7 +100,7 @@ fn scan_dir(
|
||||
}
|
||||
|
||||
async fn process_and_upload(
|
||||
config: &BichonCtlConfig,
|
||||
config: &BichonCliConfig,
|
||||
account_id: u64,
|
||||
tasks: HashMap<String, Vec<PathBuf>>,
|
||||
) {
|
||||
@@ -0,0 +1,215 @@
|
||||
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 {
|
||||
let account_ids = Some(std::collections::HashSet::from([account.id]));
|
||||
if let Some(batch) = search_messages(&client, config, account_ids, 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.clone(), &mut file)
|
||||
.await;
|
||||
|
||||
if !success {
|
||||
eprintln!(
|
||||
" ✘ Failed to export email {}, skipping...",
|
||||
envelope.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
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,134 @@
|
||||
//
|
||||
// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mail_parser::{HeaderValue, MessageParser};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn parse_x_gmail_labels(raw_message: &[u8]) -> Option<String> {
|
||||
// MessageParser::new() has an empty header_map so the hardcoded match at
|
||||
// parsers/header.rs:76 treats ALL unknown headers as raw (no RFC 2047
|
||||
// decoding). We need three things to get decoding:
|
||||
// 1. A non-empty header_map (so the else branch runs)
|
||||
// 2. default_header_text() so the fallback fn is parse_unstructured
|
||||
// 3. OR register X-Gmail-Labels explicitly via header_text()
|
||||
let message = MessageParser::new()
|
||||
.with_minimal_headers()
|
||||
.default_header_text()
|
||||
.parse(raw_message)?;
|
||||
let value: &HeaderValue<'_> = message.header("X-Gmail-Labels")?;
|
||||
value.as_text().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Construct a raw MIME message with RFC 2047 encoded X-Gmail-Labels,
|
||||
/// parse it, and verify the header is correctly decoded.
|
||||
fn build_email(x_gmail_labels: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: Test\r\n\
|
||||
X-Gmail-Labels: {}\r\n\
|
||||
\r\n\
|
||||
Body text here.\r\n",
|
||||
x_gmail_labels
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc2047_encoded_labels_are_decoded() {
|
||||
// Exactly the format the user reported: French Gmail labels
|
||||
let raw = build_email("=?UTF-8?Q?Corbeille?=, =?UTF-8?Q?Messages_archiv=C3=A9s?=");
|
||||
let labels = parse_x_gmail_labels(&raw).expect("failed to parse X-Gmail-Labels");
|
||||
|
||||
// mail-parser decodes RFC 2047 header values during initial parsing.
|
||||
// The decoded text should NOT contain raw =?UTF-8?Q?... sequences.
|
||||
assert!(!labels.contains("=?UTF-8"), "labels still encoded: {labels:?}");
|
||||
assert!(labels.contains("Corbeille"), "missing 'Corbeille': {labels:?}");
|
||||
assert!(
|
||||
labels.contains("archivés"),
|
||||
"missing decoded 'archivés': {labels:?}",
|
||||
);
|
||||
|
||||
// Full pipeline: decoded labels → determine_folder
|
||||
let folder = determine_folder(&labels);
|
||||
assert_eq!(folder, "Corbeille");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_ascii_labels_passthrough() {
|
||||
let raw = build_email("Inbox, Important");
|
||||
let labels = parse_x_gmail_labels(&raw).expect("failed to parse X-Gmail-Labels");
|
||||
assert_eq!(labels, "Inbox, Important");
|
||||
assert_eq!(determine_folder(&labels), "Important");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_x_gmail_labels_header() {
|
||||
let raw = b"From: sender@example.com\r\nTo: r@example.com\r\n\r\nBody.\r\n";
|
||||
let message = MessageParser::new().parse(raw.as_slice()).unwrap();
|
||||
assert!(message.header("X-Gmail-Labels").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api::sender::send_batch_request;
|
||||
use crate::mbox::gmail::determine_folder;
|
||||
use crate::mbox::reader::MboxFile;
|
||||
use crate::BichonCliConfig;
|
||||
use bichon_core::base64_encode_url_safe;
|
||||
use bichon_core::envelope::meta::{parse_bichon_metadata, BichonMetadata};
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use dialoguer::{Confirm, Select};
|
||||
use mail_parser::MessageParser;
|
||||
use reqwest::Client;
|
||||
|
||||
/// Skip emails larger than this with a warning (100 MB).
|
||||
const MAX_EMAIL_BYTES: usize = 100 * 1024 * 1024;
|
||||
/// Flush a folder buffer when accumulated base64 bytes exceed this (200 MB).
|
||||
const MAX_BUFFER_BYTES: usize = 200 * 1024 * 1024;
|
||||
|
||||
pub mod gmail;
|
||||
pub mod reader;
|
||||
|
||||
pub async fn handle_mbox_single_file_import(
|
||||
config: &BichonCliConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
let path_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the path to your SINGLE .mbox file")
|
||||
.validate_with(|input: &String| {
|
||||
let p = std::path::Path::new(input);
|
||||
if !p.exists() {
|
||||
return Err("The specified path does not exist.");
|
||||
}
|
||||
if !p.is_file() {
|
||||
return Err("MBOX mode requires a SINGLE file, not a directory.");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let mbox_path = PathBuf::from(path_str);
|
||||
|
||||
let options = vec![
|
||||
"Use labels from mail headers (X-Gmail-Labels)",
|
||||
"Specify a single target folder for all emails",
|
||||
"Use X-Bichon-Metadata header (Automatic)",
|
||||
];
|
||||
|
||||
let selection = Select::with_theme(theme)
|
||||
.with_prompt("How should we determine the target folder?")
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let target_folder: Option<String> = match selection {
|
||||
0 => None,
|
||||
1 => {
|
||||
let folder: String = Input::with_theme(theme)
|
||||
.with_prompt("Target folder name")
|
||||
.default("INBOX".into())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
Some(folder)
|
||||
}
|
||||
2 => None,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(ref folder) = target_folder {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Mode: Fixed folder ({})", folder)).dim()
|
||||
);
|
||||
} else {
|
||||
println!("{}", style("Mode: Dynamic (header-based)").dim());
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n{} Ready to process MBOX file: {}",
|
||||
style("✔").green(),
|
||||
style(mbox_path.display()).cyan()
|
||||
);
|
||||
|
||||
if let Ok(meta) = std::fs::metadata(&mbox_path) {
|
||||
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Processing file: {:.1} MB", size_mb)).dim()
|
||||
);
|
||||
}
|
||||
|
||||
if Confirm::with_theme(theme)
|
||||
.with_prompt("Start importing?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap()
|
||||
{
|
||||
run_import(account_id, &mbox_path, config, target_folder).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_import(
|
||||
account_id: u64,
|
||||
mbox_path: &PathBuf,
|
||||
config: &BichonCliConfig,
|
||||
target_folder: Option<String>,
|
||||
) {
|
||||
let client = Client::new();
|
||||
let mbox = match MboxFile::from_file(mbox_path) {
|
||||
Ok(mbox) => mbox,
|
||||
Err(err) => {
|
||||
println!("Skipping invalid MBOX: {} ({})", mbox_path.display(), err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut folder_buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut total_buffered_bytes: usize = 0;
|
||||
let batch_limit = 50;
|
||||
let mut skipped_count: u64 = 0;
|
||||
|
||||
println!("Starting import process...");
|
||||
|
||||
for (index, e) in mbox.iter().enumerate() {
|
||||
let msg_num = index + 1;
|
||||
let body = e.data;
|
||||
|
||||
if body.len() > MAX_EMAIL_BYTES {
|
||||
let size_mb = body.len() as f64 / 1024.0 / 1024.0;
|
||||
eprintln!(
|
||||
"{} {}: email #{} is {:.1} MB (limit 100 MB). Skipping...",
|
||||
style("Warning").yellow().bold(),
|
||||
style(format!("oversized")).dim(),
|
||||
msg_num,
|
||||
size_mb,
|
||||
);
|
||||
skipped_count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match MessageParser::new()
|
||||
.with_minimal_headers()
|
||||
.default_header_text()
|
||||
.parse(body)
|
||||
{
|
||||
Some(msg) => msg,
|
||||
None => {
|
||||
eprintln!(
|
||||
"{} {}: {}",
|
||||
style("Warning").yellow().bold(),
|
||||
style(format!("at message #{}", msg_num)).dim(),
|
||||
"Failed to parse email structure. Skipping..."
|
||||
);
|
||||
skipped_count += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut metadata: Option<BichonMetadata> = None;
|
||||
if let Some(meta_header) = message.header_raw("X-Bichon-Metadata") {
|
||||
metadata = parse_bichon_metadata(meta_header);
|
||||
}
|
||||
|
||||
let get_default_folder = || {
|
||||
let labels = message
|
||||
.header("X-Gmail-Labels")
|
||||
.and_then(|h| h.as_text())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "INBOX".to_string());
|
||||
determine_folder(&labels)
|
||||
};
|
||||
|
||||
let folder_name = if let Some(ref folder) = target_folder {
|
||||
folder.clone()
|
||||
} else if let Some(ref meta) = metadata {
|
||||
meta.mailbox_name.clone().unwrap_or_else(get_default_folder)
|
||||
} else {
|
||||
get_default_folder()
|
||||
};
|
||||
|
||||
// Drop message before base64-encoding to free MIME parse memory.
|
||||
drop(message);
|
||||
|
||||
let b64_eml = base64_encode_url_safe!(&body);
|
||||
let encoded_len = b64_eml.len();
|
||||
|
||||
let buffer = folder_buffers
|
||||
.entry(folder_name.clone())
|
||||
.or_insert_with(Vec::new);
|
||||
buffer.push(b64_eml);
|
||||
total_buffered_bytes += encoded_len;
|
||||
|
||||
if buffer.len() >= batch_limit || total_buffered_bytes >= MAX_BUFFER_BYTES {
|
||||
let emls_to_send = folder_buffers.remove(&folder_name).unwrap();
|
||||
let freed: usize = emls_to_send.iter().map(|s| s.len()).sum();
|
||||
total_buffered_bytes = total_buffered_bytes.saturating_sub(freed);
|
||||
send_batch_request(&client, config, account_id, &folder_name, emls_to_send).await;
|
||||
}
|
||||
}
|
||||
|
||||
for (folder_name, emls) in folder_buffers {
|
||||
if !emls.is_empty() {
|
||||
send_batch_request(&client, config, account_id, &folder_name, emls).await;
|
||||
}
|
||||
}
|
||||
|
||||
if skipped_count > 0 {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Skipped {} email(s) (oversized or unparseable).",
|
||||
skipped_count
|
||||
))
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
}
|
||||
|
||||
println!("{}", style("Import completed successfully!").green().bold());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Fake sender: records every flushed batch as (folder_name, email_count, total_bytes).
|
||||
struct FakeSender {
|
||||
batches: Vec<(String, usize, usize)>,
|
||||
}
|
||||
|
||||
impl FakeSender {
|
||||
fn new() -> Self {
|
||||
Self { batches: vec![] }
|
||||
}
|
||||
fn send(&mut self, folder: &str, emls: Vec<String>) {
|
||||
let count = emls.len();
|
||||
let bytes: usize = emls.iter().map(|s| s.len()).sum();
|
||||
self.batches.push((folder.to_string(), count, bytes));
|
||||
// emls is dropped here, simulating real send
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_encode(size: usize) -> String {
|
||||
// base64 expands ~1.33x, so the encoded string is roughly this long.
|
||||
// We just need a predictable byte size, so use a repeated character.
|
||||
"x".repeat(size)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_on_global_byte_threshold() {
|
||||
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut total_bytes: usize = 0;
|
||||
let batch_limit = 50;
|
||||
let mut sender = FakeSender::new();
|
||||
|
||||
// Simulate 3 emails, each 80 MB encoded, spread across 3 folders.
|
||||
// After each email, global total goes up by 80 MB.
|
||||
// After the 3rd email: 240 MB > 200 MB → flush the folder that got the 3rd email.
|
||||
let emails = vec![
|
||||
("Inbox", 80_000_000),
|
||||
("Sent", 80_000_000),
|
||||
("Archive", 80_000_000),
|
||||
];
|
||||
|
||||
for (folder, eml_size) in emails {
|
||||
let encoded = fake_encode(eml_size);
|
||||
let len = encoded.len();
|
||||
let buffer = buffers.entry(folder.to_string()).or_insert_with(Vec::new);
|
||||
buffer.push(encoded);
|
||||
total_bytes += len;
|
||||
|
||||
if buffer.len() >= batch_limit || total_bytes >= MAX_BUFFER_BYTES {
|
||||
let sent = buffers.remove(folder).unwrap();
|
||||
let freed: usize = sent.iter().map(|s| s.len()).sum();
|
||||
total_bytes = total_bytes.saturating_sub(freed);
|
||||
sender.send(folder, sent);
|
||||
}
|
||||
}
|
||||
|
||||
// The 3rd email should trigger a global flush of "Archive".
|
||||
assert_eq!(sender.batches.len(), 1);
|
||||
assert_eq!(sender.batches[0].0, "Archive");
|
||||
assert_eq!(sender.batches[0].1, 1);
|
||||
// "Inbox" and "Sent" are still buffered (160 MB total).
|
||||
assert_eq!(buffers.len(), 2);
|
||||
assert!(buffers.contains_key("Inbox"));
|
||||
assert!(buffers.contains_key("Sent"));
|
||||
assert_eq!(total_bytes, 160_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_on_count_threshold() {
|
||||
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut total_bytes: usize = 0;
|
||||
let batch_limit = 3;
|
||||
let mut sender = FakeSender::new();
|
||||
|
||||
// 4 small emails all to Inbox, well under byte threshold.
|
||||
for _ in 0..4 {
|
||||
let encoded = fake_encode(100); // tiny
|
||||
let len = encoded.len();
|
||||
let buffer = buffers
|
||||
.entry("Inbox".to_string())
|
||||
.or_insert_with(Vec::new);
|
||||
buffer.push(encoded);
|
||||
total_bytes += len;
|
||||
|
||||
if buffer.len() >= batch_limit || total_bytes >= MAX_BUFFER_BYTES {
|
||||
let sent = buffers.remove("Inbox").unwrap();
|
||||
let freed: usize = sent.iter().map(|s| s.len()).sum();
|
||||
total_bytes = total_bytes.saturating_sub(freed);
|
||||
sender.send("Inbox", sent);
|
||||
}
|
||||
}
|
||||
|
||||
// Count=3 should trigger flush once; the 4th email stays buffered.
|
||||
assert_eq!(sender.batches.len(), 1);
|
||||
assert_eq!(sender.batches[0].1, 3); // 3 emails flushed
|
||||
let remaining = buffers.get("Inbox").unwrap();
|
||||
assert_eq!(remaining.len(), 1); // 1 still buffered
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_bytes_exact_boundary() {
|
||||
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut total_bytes: usize = 0;
|
||||
let mut sender = FakeSender::new();
|
||||
|
||||
// Push one email that puts us right at 200 MB.
|
||||
let encoded = fake_encode(MAX_BUFFER_BYTES);
|
||||
let len = encoded.len();
|
||||
buffers
|
||||
.entry("Inbox".to_string())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(encoded);
|
||||
total_bytes += len;
|
||||
|
||||
if total_bytes >= MAX_BUFFER_BYTES {
|
||||
let sent = buffers.remove("Inbox").unwrap();
|
||||
let freed: usize = sent.iter().map(|s| s.len()).sum();
|
||||
total_bytes = total_bytes.saturating_sub(freed);
|
||||
sender.send("Inbox", sent);
|
||||
}
|
||||
|
||||
// Should have flushed on the boundary.
|
||||
assert_eq!(sender.batches.len(), 1);
|
||||
assert_eq!(total_bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_one_folder_does_not_lose_others() {
|
||||
let mut buffers: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut total_bytes: usize = 0;
|
||||
let batch_limit = 50;
|
||||
let mut sender = FakeSender::new();
|
||||
|
||||
// Build up A to 150 MB, B to 100 MB (total 250 MB > 200 MB).
|
||||
// A should trigger flush; B should stay buffered.
|
||||
let folder_a = "A".to_string();
|
||||
let folder_b = "B".to_string();
|
||||
|
||||
// Folder A: 150 MB
|
||||
let encoded = fake_encode(150_000_000);
|
||||
let len = encoded.len();
|
||||
buffers.entry(folder_a.clone()).or_insert_with(Vec::new).push(encoded);
|
||||
total_bytes += len;
|
||||
|
||||
// Folder B: 100 MB → total 250 MB → trigger flush on B
|
||||
let encoded = fake_encode(100_000_000);
|
||||
let len = encoded.len();
|
||||
buffers.entry(folder_b.clone()).or_insert_with(Vec::new).push(encoded);
|
||||
total_bytes += len;
|
||||
|
||||
// Check trigger on B
|
||||
let b_buffer = buffers.get(&folder_b).unwrap();
|
||||
if b_buffer.len() >= batch_limit || total_bytes >= MAX_BUFFER_BYTES {
|
||||
let sent = buffers.remove(&folder_b).unwrap();
|
||||
let freed: usize = sent.iter().map(|s| s.len()).sum();
|
||||
total_bytes = total_bytes.saturating_sub(freed);
|
||||
sender.send(&folder_b, sent);
|
||||
}
|
||||
|
||||
assert_eq!(sender.batches.len(), 1);
|
||||
assert_eq!(sender.batches[0].0, "B"); // B flushed
|
||||
assert!(buffers.contains_key("A")); // A still there
|
||||
assert_eq!(total_bytes, 150_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_oversized_email() {
|
||||
assert!(100 <= MAX_EMAIL_BYTES);
|
||||
// Use vec! so the 100 MB array lives on the heap, not the stack.
|
||||
let huge = vec![0u8; MAX_EMAIL_BYTES + 1];
|
||||
assert!(huge.len() > MAX_EMAIL_BYTES);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use memmap2::Mmap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -111,7 +129,7 @@ impl<'a> Iterator for MboxReader<'a> {
|
||||
mod tests {
|
||||
use mail_parser::MessageParser;
|
||||
|
||||
use crate::modules::cli::mbox::gmail::determine_folder;
|
||||
use crate::mbox::gmail::determine_folder;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -202,7 +220,6 @@ mod tests {
|
||||
labels,
|
||||
determine_folder(labels)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,22 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use compressed_rtf::*;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::Input;
|
||||
@@ -5,10 +23,10 @@ use mail_send::mail_builder::headers::text::Text;
|
||||
use mail_send::mail_builder::MessageBuilder;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
use crate::base64_encode_url_safe;
|
||||
use crate::modules::cli::pst::encoding::decode_subject;
|
||||
use crate::modules::cli::sender::send_batch_request;
|
||||
use crate::modules::cli::BichonCtlConfig;
|
||||
use 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;
|
||||
@@ -42,7 +60,7 @@ pub struct EmailAttachment {
|
||||
pub data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) {
|
||||
pub async fn handle_pst_import(config: &BichonCliConfig, account_id: u64, theme: &ColorfulTheme) {
|
||||
let path_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the path to your SINGLE .pst file")
|
||||
.validate_with(|input: &String| {
|
||||
@@ -97,7 +115,7 @@ pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme:
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) {
|
||||
async fn parse_pst(pst_path: PathBuf, config: &BichonCliConfig, account_id: u64) {
|
||||
let client = Client::new();
|
||||
|
||||
let pst_store = match outlook_pst::open_store(&pst_path) {
|
||||
@@ -143,7 +161,7 @@ fn process_folder_recursively<'a>(
|
||||
client: &'a Client,
|
||||
folder: &'a Rc<dyn Folder>,
|
||||
parent_path: &'a str,
|
||||
config: &'a BichonCtlConfig,
|
||||
config: &'a BichonCliConfig,
|
||||
account_id: u64,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
|
||||
Box::pin(async move {
|
||||
@@ -342,48 +360,54 @@ fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<Strin
|
||||
let mut bcc = Vec::new();
|
||||
|
||||
let recipient_table = message.recipient_table();
|
||||
let context = recipient_table.context();
|
||||
if let Some(recipient_table) = recipient_table {
|
||||
let context = recipient_table.context();
|
||||
for row in recipient_table.rows_matrix() {
|
||||
if let Ok(cols) = row.columns(context) {
|
||||
let mut r_type = 0;
|
||||
let mut email = String::new();
|
||||
|
||||
for row in recipient_table.rows_matrix() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if !email.is_empty() {
|
||||
match r_type {
|
||||
1 => to.push(email),
|
||||
2 => cc.push(email),
|
||||
3 => bcc.push(email),
|
||||
_ => {}
|
||||
}
|
||||
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: &BichonCtlConfig,
|
||||
config: &BichonCliConfig,
|
||||
account_id: u64,
|
||||
folder_path: &str,
|
||||
emls: Vec<String>,
|
||||
@@ -1,11 +1,29 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use crate::modules::cli::{mbox::run_import, BichonCtlConfig};
|
||||
use crate::{mbox::run_import, BichonCliConfig};
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||
|
||||
pub async fn handle_thunderbird_import(
|
||||
config: &BichonCtlConfig,
|
||||
config: &BichonCliConfig,
|
||||
account_id: u64,
|
||||
theme: &ColorfulTheme,
|
||||
) {
|
||||
@@ -0,0 +1,74 @@
|
||||
[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"
|
||||
cron = "0.15"
|
||||
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]
|
||||
@@ -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,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 poem_openapi::Object;
|
||||
//use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
raise_error, utc_now,
|
||||
{
|
||||
account::migration::AccountModel,
|
||||
common::auth::ClientContext,
|
||||
database::{manager::DB_MANAGER, with_transaction},
|
||||
database::{manager::DB_MANAGER, with_transaction, MemDbModel},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
users::{
|
||||
permissions::Permission,
|
||||
@@ -31,10 +32,10 @@ use crate::{
|
||||
UserModel,
|
||||
},
|
||||
},
|
||||
raise_error, utc_now,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
pub struct BatchAccountRoleRequest {
|
||||
pub account_ids: Vec<u64>,
|
||||
pub user_ids: Vec<u64>,
|
||||
@@ -42,8 +43,8 @@ pub struct BatchAccountRoleRequest {
|
||||
}
|
||||
|
||||
impl BatchAccountRoleRequest {
|
||||
pub async fn validate_existence(&self) -> BichonResult<()> {
|
||||
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
|
||||
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
|
||||
@@ -58,7 +59,7 @@ impl BatchAccountRoleRequest {
|
||||
}
|
||||
|
||||
for id in &self.account_ids {
|
||||
let exists = AccountModel::find(*id).await?; // Assuming an exists helper
|
||||
let exists = AccountModel::find(*id)?; // Assuming an exists helper
|
||||
if exists.is_none() {
|
||||
return Err(raise_error!(
|
||||
format!("Account ID {} not found", id),
|
||||
@@ -68,7 +69,7 @@ impl BatchAccountRoleRequest {
|
||||
}
|
||||
|
||||
for id in &self.user_ids {
|
||||
let exists = UserModel::find(*id).await?; // Assuming an exists helper
|
||||
let exists = UserModel::find(*id)?; // Assuming an exists helper
|
||||
if exists.is_none() {
|
||||
return Err(raise_error!(
|
||||
format!("User ID {} not found", id),
|
||||
@@ -80,44 +81,38 @@ impl BatchAccountRoleRequest {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn grant_batch_account_access(
|
||||
fn grant_batch_account_access(
|
||||
account_ids: Vec<u64>,
|
||||
user_ids: Vec<u64>,
|
||||
role_id: u64,
|
||||
) -> BichonResult<()> {
|
||||
with_transaction(DB_MANAGER.meta_db(), move |rw| {
|
||||
with_transaction(DB_MANAGER.db(), move |txn| {
|
||||
let mut txn = txn;
|
||||
for &uid in &user_ids {
|
||||
// Fetch the current user record from the database
|
||||
let user = rw
|
||||
.get()
|
||||
.primary::<UserModel>(uid)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("User with id={} not found.", uid),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
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();
|
||||
|
||||
// Apply the role to each specified account_id
|
||||
for &aid in &account_ids {
|
||||
updated_user.account_access_map.insert(aid, role_id);
|
||||
}
|
||||
|
||||
updated_user.updated_at = utc_now!();
|
||||
|
||||
// Save the updated user back to the database within the transaction
|
||||
rw.update(user, updated_user)
|
||||
txn = txn
|
||||
.upsert(UserModel::collection(), key, &updated_user)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(txn)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
|
||||
pub fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
|
||||
for account_id in &self.account_ids {
|
||||
// Get the user's specific access for this account
|
||||
let assigned_role_id =
|
||||
@@ -133,7 +128,7 @@ impl BatchAccountRoleRequest {
|
||||
})?;
|
||||
|
||||
// Fetch the role definition from the database
|
||||
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
|
||||
let user_scoped_role = UserRole::find(*assigned_role_id)?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Assigned account role no longer exists".into(),
|
||||
ErrorCode::InternalError
|
||||
@@ -155,6 +150,6 @@ impl BatchAccountRoleRequest {
|
||||
// This is where you'd compare target_role.permissions vs manager's perms
|
||||
}
|
||||
|
||||
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
|
||||
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
//
|
||||
// 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 download_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub download_interval_min: Option<i64>,
|
||||
pub download_batch_size: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
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>,
|
||||
pub download_schedule: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
use_dangerous: request.use_dangerous,
|
||||
pgp_key: request.pgp_key,
|
||||
created_by: user_id,
|
||||
download_batch_size: request.download_batch_size,
|
||||
max_email_size_bytes: request.max_email_size_bytes,
|
||||
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,
|
||||
download_schedule: request.download_schedule,
|
||||
})
|
||||
}
|
||||
|
||||
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(account_name) = request.account_name {
|
||||
new.account_name = Some(account_name);
|
||||
}
|
||||
|
||||
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(max_email_size_bytes) = request.max_email_size_bytes {
|
||||
new.max_email_size_bytes = Some(max_email_size_bytes);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if let Some(download_schedule) = request.download_schedule {
|
||||
new.download_schedule = Some(download_schedule);
|
||||
}
|
||||
if request.clear_download_schedule == Some(true) {
|
||||
new.download_schedule = None;
|
||||
}
|
||||
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,11 +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 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,34 +16,46 @@
|
||||
// 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::ImapConfig;
|
||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
||||
use crate::modules::account::since::{DateSince, RelativeDate};
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
use std::str::FromStr;
|
||||
|
||||
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")))]
|
||||
pub folder_limit: Option<u32>,
|
||||
#[oai(validator(minimum(value = "10")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
||||
pub sync_batch_size: Option<u32>,
|
||||
#[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 max_email_size_bytes: Option<u64>,
|
||||
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>,
|
||||
pub download_schedule: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountCreateRequest {
|
||||
@@ -56,6 +68,13 @@ impl AccountCreateRequest {
|
||||
));
|
||||
}
|
||||
|
||||
if self.imap_quota_bytes.is_some() ^ self.imap_quota_window.is_some() {
|
||||
return Err(raise_error!(
|
||||
"Quota bytes and quota window must be provided together or omitted together".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(date_since) = self.date_since.as_ref() {
|
||||
date_since.validate()?;
|
||||
}
|
||||
@@ -75,12 +94,15 @@ impl AccountCreateRequest {
|
||||
))
|
||||
}
|
||||
}
|
||||
if self.sync_interval_min.is_none() {
|
||||
if self.download_interval_min.is_none() && self.download_schedule.is_none() {
|
||||
return Err(raise_error!(
|
||||
"`sync_interval_min` is required for IMAP account type".into(),
|
||||
"`sync_interval_min` or `download_schedule` is required for IMAP account type".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if let Some(ref schedule) = self.download_schedule {
|
||||
validate_cron_expression(schedule)?;
|
||||
}
|
||||
}
|
||||
AccountType::NoSync => {}
|
||||
}
|
||||
@@ -96,7 +118,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.
|
||||
@@ -105,8 +128,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
|
||||
@@ -122,12 +144,6 @@ pub struct AccountUpdateRequest {
|
||||
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")))]
|
||||
pub folder_limit: Option<u32>,
|
||||
pub clear_folder_limit: Option<bool>,
|
||||
/// Configuration for selective folder (mailbox/label) synchronization
|
||||
///
|
||||
/// - For IMAP/SMTP accounts:
|
||||
@@ -142,11 +158,15 @@ 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")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
|
||||
pub sync_batch_size: Option<u32>,
|
||||
/// 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>,
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
/// 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.
|
||||
@@ -155,6 +175,11 @@ 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>,
|
||||
pub download_schedule: Option<String>,
|
||||
pub clear_download_schedule: Option<bool>,
|
||||
}
|
||||
|
||||
impl AccountUpdateRequest {
|
||||
@@ -167,9 +192,9 @@ impl AccountUpdateRequest {
|
||||
));
|
||||
}
|
||||
|
||||
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
|
||||
if self.imap_quota_bytes.is_some() ^ self.imap_quota_window.is_some() {
|
||||
return Err(raise_error!(
|
||||
"clear_folder_limit cannot be combined with folder_limit".into(),
|
||||
"Quota bytes and quota window must be provided together or omitted together".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
@@ -200,12 +225,38 @@ impl AccountUpdateRequest {
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.clear_download_schedule == Some(true) && self.download_schedule.is_some() {
|
||||
return Err(raise_error!(
|
||||
"clear_download_schedule cannot be combined with download_schedule".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if let Some(ref schedule) = self.download_schedule {
|
||||
validate_cron_expression(schedule)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
fn validate_cron_expression(expr: &str) -> BichonResult<()> {
|
||||
if expr.trim().is_empty() {
|
||||
return Err(raise_error!(
|
||||
"download_schedule must not be empty".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
cron::Schedule::from_str(expr).map_err(|e| {
|
||||
raise_error!(
|
||||
format!("Invalid cron expression '{}': {}", expr, e),
|
||||
ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
|
||||
pub struct MinimalAccount {
|
||||
pub id: u64,
|
||||
@@ -222,3 +273,34 @@ pub fn filter_accessible_accounts<'a>(
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::validate_cron_expression;
|
||||
|
||||
#[test]
|
||||
fn valid_cron_expressions() {
|
||||
assert!(validate_cron_expression("0 0 0 * * *").is_ok()); // daily at midnight
|
||||
assert!(validate_cron_expression("0 */5 * * * *").is_ok()); // every 5 minutes
|
||||
assert!(validate_cron_expression("0 0 12 * * 1-5").is_ok()); // weekdays at noon
|
||||
assert!(validate_cron_expression("0 30 4 1 * *").is_ok()); // 1st of month at 04:30
|
||||
assert!(validate_cron_expression("0 0 * * * *").is_ok()); // every hour
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cron_expression_too_few_fields() {
|
||||
assert!(validate_cron_expression("0 0 * *").is_err());
|
||||
assert!(validate_cron_expression("* * * * *").is_err()); // 5 fields, needs seconds
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cron_expression_empty() {
|
||||
assert!(validate_cron_expression("").is_err());
|
||||
assert!(validate_cron_expression(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cron_expression_garbage() {
|
||||
assert!(validate_cron_expression("not a cron").is_err());
|
||||
}
|
||||
}
|
||||
@@ -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,14 +17,14 @@
|
||||
// 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)
|
||||
///
|
||||
@@ -38,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
|
||||
///
|
||||
@@ -58,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,
|
||||
@@ -66,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,
|
||||
}
|
||||
|
||||
@@ -255,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 {
|
||||
@@ -275,9 +305,102 @@ 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());
|
||||
}
|
||||
|
||||
// ── Sliding window tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn relative_date_calculate_returns_valid_format() {
|
||||
let r = RelativeDate {
|
||||
unit: Unit::Years,
|
||||
value: 1,
|
||||
};
|
||||
let date_str = r.calculate_date().unwrap();
|
||||
// Expect format like "26-May-2025"
|
||||
assert!(date_str.len() > 5);
|
||||
assert!(date_str.contains('-'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_date_one_year_ago_is_before_now() {
|
||||
let r = RelativeDate {
|
||||
unit: Unit::Years,
|
||||
value: 1,
|
||||
};
|
||||
let date_str = r.calculate_date().unwrap();
|
||||
let parsed = chrono::NaiveDate::parse_from_str(&date_str, "%d-%b-%Y").unwrap();
|
||||
let today = chrono::Local::now().date_naive();
|
||||
assert!(parsed < today, "1 year ago ({parsed}) should be before today ({today})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_date_one_day_ago_is_yesterday() {
|
||||
let r = RelativeDate {
|
||||
unit: Unit::Days,
|
||||
value: 1,
|
||||
};
|
||||
let date_str = r.calculate_date().unwrap();
|
||||
let parsed = chrono::NaiveDate::parse_from_str(&date_str, "%d-%b-%Y").unwrap();
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let yesterday = today - chrono::Duration::days(1);
|
||||
assert_eq!(parsed, yesterday, "1 day ago should be yesterday");
|
||||
}
|
||||
}
|
||||
@@ -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,9 +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};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test() {
|
||||
let config = autoconfig::from_addr("test@gmail.com").await.unwrap();
|
||||
println!("{:#?}", config);
|
||||
#[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,
|
||||
}
|
||||
@@ -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,34 +17,34 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::modules::{
|
||||
use crate::{
|
||||
account::{
|
||||
entity::ImapConfig,
|
||||
migration::{AccountModel, AccountType},
|
||||
migration::{AccountModel, AccountType, QuotaWindow},
|
||||
since::{DateSince, RelativeDate},
|
||||
},
|
||||
users::UserModel,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
pub struct AccountResp {
|
||||
pub id: u64,
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
pub email: String,
|
||||
pub name: Option<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 sync_folders: Option<Vec<String>>,
|
||||
pub download_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub sync_interval_min: Option<i64>,
|
||||
pub sync_batch_size: Option<u32>,
|
||||
pub download_interval_min: Option<i64>,
|
||||
pub download_batch_size: Option<u32>,
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
@@ -54,6 +54,10 @@ pub struct AccountResp {
|
||||
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>,
|
||||
pub download_schedule: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountResp {
|
||||
@@ -64,15 +68,16 @@ impl AccountResp {
|
||||
imap: account.imap,
|
||||
enabled: account.enabled,
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
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,
|
||||
sync_folders: account.sync_folders,
|
||||
download_folders: account.download_folders,
|
||||
account_type: account.account_type,
|
||||
sync_interval_min: account.sync_interval_min,
|
||||
sync_batch_size: account.sync_batch_size,
|
||||
download_interval_min: account.download_interval_min,
|
||||
download_batch_size: account.download_batch_size,
|
||||
max_email_size_bytes: account.max_email_size_bytes,
|
||||
known_folders: account.known_folders,
|
||||
created_at: account.created_at,
|
||||
updated_at: account.updated_at,
|
||||
@@ -86,6 +91,10 @@ impl AccountResp {
|
||||
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,
|
||||
download_schedule: account.download_schedule,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
pub mod meta;
|
||||
@@ -0,0 +1,339 @@
|
||||
//
|
||||
// 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,
|
||||
/// Authentication method from the XML, e.g. "OAuth2", "password-cleartext",
|
||||
/// "password-encrypted", "GSSAPI", "NTLM". Absent in DNS SRV fallback.
|
||||
#[serde(default)]
|
||||
pub authentication: 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(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
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), DNS SRV, MX fallback, and finally guessing.
|
||||
///
|
||||
/// Probe order:
|
||||
/// 1. `https://autoconfig.{domain}/mail/config-v1.1.xml`
|
||||
/// 2. `http://autoconfig.{domain}/mail/config-v1.1.xml`
|
||||
/// 3. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
|
||||
/// 4. `http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
|
||||
/// 5. DNS SRV records (`_imaps._tcp` / `_submission._tcp`)
|
||||
/// 6. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`)
|
||||
/// 7. MX lookup → ISPDB for MX domain
|
||||
/// 8. MX lookup → ISP autoconfig for MX domain
|
||||
/// 9. GuessConfig — probe common hostnames + ports
|
||||
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))?;
|
||||
|
||||
// ── ISP autoconfig (HTTPS, then HTTP) ──────────────────────────
|
||||
if let Some(config) =
|
||||
fetch_xml(&client, &format!("https://autoconfig.{domain}/mail/config-v1.1.xml")).await
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
if let Some(config) =
|
||||
fetch_xml(&client, &format!("http://autoconfig.{domain}/mail/config-v1.1.xml")).await
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// ── Well-known path (HTTPS, then HTTP) ─────────────────────────
|
||||
if let Some(config) = fetch_xml(
|
||||
&client,
|
||||
&format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
if let Some(config) = fetch_xml(
|
||||
&client,
|
||||
&format!("http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// ── DNS SRV records ────────────────────────────────────────────
|
||||
if let Some(config) = lookup_srv(domain).await {
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// ── Thunderbird central ISPDB ──────────────────────────────────
|
||||
if let Some(config) =
|
||||
fetch_xml(&client, &format!("https://autoconfig.thunderbird.net/v1.1/{domain}")).await
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// ── MX fallback ────────────────────────────────────────────────
|
||||
if let Some(config) = fetch_for_mx(&client, domain).await {
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// ── GuessConfig ────────────────────────────────────────────────
|
||||
if let Some(config) = crate::autoconfig::guess::guess_config(domain).await {
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
Err(raise_error!(
|
||||
format!("No autoconfig found for domain: {domain}"),
|
||||
ErrorCode::InternalError
|
||||
))
|
||||
}
|
||||
|
||||
/// DNS MX lookup → retry ISPDB and ISP autoconfig for the MX domain.
|
||||
///
|
||||
/// Many self-hosted domains have their MX pointed at Google, Microsoft, etc.
|
||||
/// The MX domain's ISPDB entry covers the original domain.
|
||||
async fn fetch_for_mx(client: &Client, domain: &str) -> Option<MailConfig> {
|
||||
let mx_domain = lookup_mx_domain(domain).await?;
|
||||
if mx_domain == domain.to_ascii_lowercase() {
|
||||
return None; // same domain, already tried above
|
||||
}
|
||||
|
||||
// Try ISPDB for the MX domain
|
||||
if let Some(config) =
|
||||
fetch_xml(client, &format!("https://autoconfig.thunderbird.net/v1.1/{mx_domain}")).await
|
||||
{
|
||||
return Some(config);
|
||||
}
|
||||
|
||||
// Try ISP autoconfig for the MX domain (HTTPS then HTTP)
|
||||
if let Some(config) =
|
||||
fetch_xml(client, &format!("https://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await
|
||||
{
|
||||
return Some(config);
|
||||
}
|
||||
if let Some(config) =
|
||||
fetch_xml(client, &format!("http://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await
|
||||
{
|
||||
return Some(config);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// DNS MX lookup → extract the second-level domain of the first MX hostname.
|
||||
async fn lookup_mx_domain(domain: &str) -> Option<String> {
|
||||
let resolver = TokioResolver::builder(TokioConnectionProvider::default())
|
||||
.ok()?
|
||||
.build();
|
||||
let lookup = resolver.mx_lookup(domain).await.ok()?;
|
||||
let record = lookup.iter().next()?;
|
||||
let mx_host = record.to_string().trim_end_matches('.').to_string();
|
||||
|
||||
// Extract a reasonable base domain from the MX hostname.
|
||||
// E.g., "aspmx.l.google.com" → "google.com"
|
||||
// "company.mail.protection.outlook.com" → "outlook.com"
|
||||
extract_base_domain(&mx_host)
|
||||
}
|
||||
|
||||
/// Extract the top two labels from a hostname as a rough base domain.
|
||||
fn extract_base_domain(host: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = host.split('.').collect();
|
||||
if parts.len() >= 2 {
|
||||
Some(parts[parts.len() - 2..].join("."))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[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,105 @@
|
||||
//
|
||||
// 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::{IncomingServer, MailConfig};
|
||||
use crate::imap::client::Client;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// A single host:port:encryption combination to probe.
|
||||
struct Guess {
|
||||
hostname: String,
|
||||
port: u16,
|
||||
encryption: Encryption,
|
||||
socket_type: &'static str,
|
||||
}
|
||||
|
||||
/// Generate candidates in the same order Thunderbird uses:
|
||||
/// 1. imap.{domain} — most common
|
||||
/// 2. mail.{domain} — fallback
|
||||
/// 3. {domain} — bare domain (rare)
|
||||
fn make_guesses(domain: &str) -> Vec<Guess> {
|
||||
let hosts = [
|
||||
format!("imap.{domain}"),
|
||||
format!("mail.{domain}"),
|
||||
domain.to_string(),
|
||||
];
|
||||
|
||||
let mut guesses = Vec::with_capacity(hosts.len() * 2);
|
||||
for host in &hosts {
|
||||
guesses.push(Guess {
|
||||
hostname: host.clone(),
|
||||
port: 993,
|
||||
encryption: Encryption::Ssl,
|
||||
socket_type: "SSL",
|
||||
});
|
||||
guesses.push(Guess {
|
||||
hostname: host.clone(),
|
||||
port: 143,
|
||||
encryption: Encryption::StartTls,
|
||||
socket_type: "STARTTLS",
|
||||
});
|
||||
}
|
||||
guesses
|
||||
}
|
||||
|
||||
/// Try to open a connection, read the IMAP banner, and close.
|
||||
/// Returns `true` if the server responds with an IMAP greeting.
|
||||
async fn probe(hostname: &str, port: u16, encryption: &Encryption) -> bool {
|
||||
match Client::connection(hostname, encryption, port, None, true).await {
|
||||
Ok(_) => {
|
||||
debug!("GuessConfig probe succeeded: {hostname}:{port} ({encryption:?})");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("GuessConfig probe failed for {hostname}:{port}: {e:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thunderbird-style guessing: try common hostnames and ports, probing
|
||||
/// each with a real TCP connection.
|
||||
///
|
||||
/// Returns the first working `MailConfig`, or `None` if nothing works.
|
||||
pub async fn guess_config(domain: &str) -> Option<MailConfig> {
|
||||
let guesses = make_guesses(domain);
|
||||
info!("GuessConfig: trying {} candidates for {domain}", guesses.len());
|
||||
|
||||
for g in &guesses {
|
||||
if probe(&g.hostname, g.port, &g.encryption).await {
|
||||
info!(
|
||||
"GuessConfig: found working IMAP at {}:{} ({})",
|
||||
g.hostname, g.port, g.socket_type
|
||||
);
|
||||
return Some(MailConfig {
|
||||
incoming: vec![IncomingServer {
|
||||
protocol: "imap".to_string(),
|
||||
hostname: g.hostname.clone(),
|
||||
port: g.port,
|
||||
socket_type: g.socket_type.to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// 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::oauth2_providers::lookup_oauth2;
|
||||
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,
|
||||
}
|
||||
};
|
||||
|
||||
// Detect OAuth2 support: the XML <authentication> field and a known
|
||||
// hostname → issuer mapping determine whether the provider supports OAuth2.
|
||||
let oauth2 = if imap.authentication.eq_ignore_ascii_case("OAuth2") {
|
||||
lookup_oauth2(&imap.hostname)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(MailServerConfig {
|
||||
imap: ServerConfig::new(imap.hostname.clone(), port, encryption),
|
||||
oauth2,
|
||||
})
|
||||
}
|
||||
|
||||
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,65 +16,56 @@
|
||||
// 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 guess;
|
||||
pub mod load;
|
||||
mod oauth2_providers;
|
||||
#[cfg(test)]
|
||||
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,151 @@
|
||||
//
|
||||
// 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::autoconfig::entity::OAuth2Config;
|
||||
|
||||
/// Per-provider OAuth2 metadata, mirroring Thunderbird's `OAuth2Providers.sys.mjs`.
|
||||
///
|
||||
/// Each entry maps one or more IMAP hostname suffixes to a well-known OIDC issuer
|
||||
/// and the IMAP-specific OAuth2 scopes.
|
||||
struct Provider {
|
||||
/// Suffixes matched case-insensitively against the end of the IMAP hostname.
|
||||
host_suffixes: &'static [&'static str],
|
||||
/// The OIDC issuer URL used by the provider.
|
||||
issuer: &'static str,
|
||||
/// OAuth2 scope(s) required for IMAP access.
|
||||
scopes: &'static [&'static str],
|
||||
}
|
||||
|
||||
const PROVIDERS: &[Provider] = &[
|
||||
// Google
|
||||
Provider {
|
||||
host_suffixes: &["imap.gmail.com", ".gmail.com", ".googlemail.com"],
|
||||
issuer: "https://accounts.google.com",
|
||||
scopes: &["https://mail.google.com/"],
|
||||
},
|
||||
// Microsoft (Outlook / Office 365 / Hotmail / Live)
|
||||
Provider {
|
||||
host_suffixes: &[
|
||||
"outlook.office365.com",
|
||||
".outlook.com",
|
||||
".hotmail.com",
|
||||
".live.com",
|
||||
".office365.com",
|
||||
],
|
||||
issuer: "https://login.microsoftonline.com/common/v2.0",
|
||||
scopes: &[
|
||||
"https://outlook.office365.com/IMAP.AccessAsUser.All",
|
||||
"offline_access",
|
||||
],
|
||||
},
|
||||
// Yahoo / AOL / ATT / Verizon
|
||||
Provider {
|
||||
host_suffixes: &[
|
||||
"imap.mail.yahoo.com",
|
||||
".yahoo.com",
|
||||
".yahoodns.net",
|
||||
".aol.com",
|
||||
"imap.aol.com",
|
||||
],
|
||||
issuer: "https://login.yahoo.com",
|
||||
scopes: &["mail-w"],
|
||||
},
|
||||
// Yandex
|
||||
Provider {
|
||||
host_suffixes: &["imap.yandex.ru", "imap.yandex.com", ".yandex.ru"],
|
||||
issuer: "https://oauth.yandex.com",
|
||||
scopes: &["imap:all"],
|
||||
},
|
||||
// Mail.ru
|
||||
Provider {
|
||||
host_suffixes: &["imap.mail.ru", ".mail.ru", ".bk.ru", ".list.ru", ".inbox.ru"],
|
||||
issuer: "https://o2.mail.ru",
|
||||
scopes: &["imap"],
|
||||
},
|
||||
// Fastmail
|
||||
Provider {
|
||||
host_suffixes: &["imap.fastmail.com", ".fastmail.com"],
|
||||
issuer: "https://www.fastmail.com",
|
||||
scopes: &[
|
||||
"https://www.fastmail.com/dev/imap",
|
||||
"offline_access",
|
||||
],
|
||||
},
|
||||
// Comcast
|
||||
Provider {
|
||||
host_suffixes: &["imap.comcast.net", ".comcast.net"],
|
||||
issuer: "https://oauth.xfinity.com",
|
||||
scopes: &["https://email.comcast.net/"],
|
||||
},
|
||||
];
|
||||
|
||||
/// Try to find an OAuth2 provider that matches the given IMAP hostname.
|
||||
///
|
||||
/// Matching is case-insensitive and done by suffix: a hostname "imap.gmail.com"
|
||||
/// matches the suffix ".gmail.com".
|
||||
pub fn lookup_oauth2(hostname: &str) -> Option<OAuth2Config> {
|
||||
let host = hostname.to_ascii_lowercase();
|
||||
for provider in PROVIDERS {
|
||||
if provider
|
||||
.host_suffixes
|
||||
.iter()
|
||||
.any(|suffix| host.ends_with(&suffix.to_ascii_lowercase()))
|
||||
{
|
||||
return Some(OAuth2Config {
|
||||
issuer: provider.issuer.to_string(),
|
||||
scope: provider.scopes.iter().map(|s| s.to_string()).collect(),
|
||||
auth_url: String::new(),
|
||||
token_url: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_known_providers() {
|
||||
let cases = [
|
||||
("imap.gmail.com", Some("https://accounts.google.com")),
|
||||
("imap.gmail.com", Some("https://accounts.google.com")),
|
||||
("outlook.office365.com", Some("https://login.microsoftonline.com/common/v2.0")),
|
||||
("imap.mail.yahoo.com", Some("https://login.yahoo.com")),
|
||||
("imap.aol.com", Some("https://login.yahoo.com")),
|
||||
("imap.yandex.ru", Some("https://oauth.yandex.com")),
|
||||
("imap.mail.ru", Some("https://o2.mail.ru")),
|
||||
("imap.fastmail.com", Some("https://www.fastmail.com")),
|
||||
("imap.comcast.net", Some("https://oauth.xfinity.com")),
|
||||
];
|
||||
for (hostname, expected_issuer) in &cases {
|
||||
let result = lookup_oauth2(hostname);
|
||||
assert_eq!(
|
||||
result.map(|c| c.issuer),
|
||||
expected_issuer.map(|s| s.to_string()),
|
||||
"failed for hostname: {hostname}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_provider() {
|
||||
assert!(lookup_oauth2("mail.my-company.example").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//
|
||||
// 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(),
|
||||
authentication: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
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(),
|
||||
authentication: String::new(),
|
||||
},
|
||||
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(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
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(),
|
||||
authentication: String::new(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
let result = mail_config_to_server_config(&config).expect("should recognize 'IMAP'");
|
||||
assert_eq!(result.imap.host, "imap.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_gmail_oauth2() {
|
||||
let config = MailConfig {
|
||||
incoming: vec![IncomingServer {
|
||||
protocol: "imap".to_string(),
|
||||
hostname: "imap.gmail.com".to_string(),
|
||||
port: 993,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: "OAuth2".to_string(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
let result = mail_config_to_server_config(&config).expect("should convert");
|
||||
let oauth2 = result.oauth2.expect("Gmail should have OAuth2");
|
||||
assert_eq!(oauth2.issuer, "https://accounts.google.com");
|
||||
assert!(oauth2.scope.contains(&"https://mail.google.com/".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_outlook_oauth2() {
|
||||
let config = MailConfig {
|
||||
incoming: vec![IncomingServer {
|
||||
protocol: "imap".to_string(),
|
||||
hostname: "outlook.office365.com".to_string(),
|
||||
port: 993,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: "OAuth2".to_string(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
let result = mail_config_to_server_config(&config).expect("should convert");
|
||||
let oauth2 = result.oauth2.expect("Outlook should have OAuth2");
|
||||
assert!(oauth2.issuer.contains("microsoftonline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_unknown_host_no_oauth2() {
|
||||
// OAuth2 auth flag on an unknown hostname → no OAuth2 returned
|
||||
let config = MailConfig {
|
||||
incoming: vec![IncomingServer {
|
||||
protocol: "imap".to_string(),
|
||||
hostname: "mail.random-isp.example".to_string(),
|
||||
port: 993,
|
||||
socket_type: "SSL".to_string(),
|
||||
username: "%EMAILADDRESS%".to_string(),
|
||||
authentication: "OAuth2".to_string(),
|
||||
}],
|
||||
outgoing: vec![],
|
||||
};
|
||||
let result = mail_config_to_server_config(&config).expect("should convert");
|
||||
assert!(result.oauth2.is_none(), "unknown hostname → no OAuth2 mapping");
|
||||
}
|
||||
+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,156 @@
|
||||
//
|
||||
// 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::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Local, TimeZone, Utc};
|
||||
use cron::Schedule;
|
||||
|
||||
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 => {
|
||||
let now = utc_now!();
|
||||
let cooldown_ok = now - state.last_finished_at.unwrap_or(0) > 60 * 1000;
|
||||
if !cooldown_ok {
|
||||
false
|
||||
} else if let Some(ref schedule) = account.download_schedule {
|
||||
should_trigger_scheduled(schedule, state.last_trigger_at)
|
||||
} else {
|
||||
should_trigger_next_download(
|
||||
state.last_trigger_at,
|
||||
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, sync_interval_min: i64) -> bool {
|
||||
let now = utc_now!();
|
||||
now - last_trigger_at > (sync_interval_min * 60 * 1000)
|
||||
}
|
||||
|
||||
fn should_trigger_scheduled(schedule_str: &str, last_trigger_at: i64) -> bool {
|
||||
let schedule = match Schedule::from_str(schedule_str) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Invalid cron expression '{}', falling back to no trigger: {}",
|
||||
schedule_str,
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// last_trigger_at is a UTC millis timestamp; convert to server local time
|
||||
let last_utc = match Utc.timestamp_millis_opt(last_trigger_at) {
|
||||
chrono::LocalResult::Single(dt) => dt,
|
||||
_ => {
|
||||
tracing::warn!("Invalid last_trigger_at timestamp: {}", last_trigger_at);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let last_dt: DateTime<Local> = last_utc.with_timezone(&Local);
|
||||
let now = Local::now();
|
||||
schedule
|
||||
.after(&last_dt)
|
||||
.next()
|
||||
.map_or(false, |next| next <= now)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cron_every_minute_triggers_after_60s() {
|
||||
// "0 * * * * *" = every minute at second 0. last_trigger 90s ago → should trigger
|
||||
let now = Local::now();
|
||||
let last_trigger = now.timestamp_millis() - 90_000;
|
||||
assert!(should_trigger_scheduled("0 * * * * *", last_trigger));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_daily_midnight_triggers_when_missed() {
|
||||
// "0 0 0 * * *" = daily at midnight
|
||||
// last_trigger was 25 hours ago → should trigger (we missed midnight)
|
||||
let now = Local::now();
|
||||
let last_trigger = now.timestamp_millis() - 25 * 60 * 60 * 1000;
|
||||
assert!(should_trigger_scheduled("0 0 0 * * *", last_trigger));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_daily_midnight_no_trigger_if_already_fired() {
|
||||
// "0 0 0 * * *" = daily at midnight
|
||||
// last_trigger was 1 minute ago → should NOT trigger
|
||||
let now = Local::now();
|
||||
let last_trigger = now.timestamp_millis() - 60_000;
|
||||
assert!(!should_trigger_scheduled("0 0 0 * * *", last_trigger));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cron_returns_false() {
|
||||
assert!(!should_trigger_scheduled("invalid cron expression", 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_every_hour_triggers() {
|
||||
// "0 0 * * * *" = every hour at minute 0, second 0
|
||||
// last_trigger was 61 minutes ago → should trigger
|
||||
let now = Local::now();
|
||||
let last_trigger = now.timestamp_millis() - 61 * 60 * 1000;
|
||||
assert!(should_trigger_scheduled("0 0 * * * *", last_trigger));
|
||||
}
|
||||
}
|
||||
+662
@@ -0,0 +1,662 @@
|
||||
//
|
||||
// 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::{
|
||||
generate_uid_sequence_hashset, ImapExecutor, DEFAULT_BATCH_SIZE,
|
||||
},
|
||||
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||
},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
|
||||
#[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<Option<u32>> {
|
||||
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(None);
|
||||
}
|
||||
|
||||
// sort small -> bigger
|
||||
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
|
||||
uid_vec.sort();
|
||||
|
||||
let max_uid = uid_vec.last().copied();
|
||||
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,
|
||||
);
|
||||
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,
|
||||
account.max_email_size_bytes,
|
||||
token.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(processed) => {
|
||||
current_processed += processed;
|
||||
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(max_uid)
|
||||
}
|
||||
|
||||
/// Fetches all messages from a mailbox.
|
||||
/// Returns `Ok(Some(max_uid))` with the highest UID stored, or `Ok(None)` if empty.
|
||||
pub async fn fetch_and_save_full_mailbox(
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<Option<u32>> {
|
||||
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 page_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
|
||||
let total_batches = total.div_ceil(page_size as u64);
|
||||
|
||||
info!(
|
||||
"Starting full mailbox download for '{}', total={}, batches={}",
|
||||
mailbox.name, total, total_batches
|
||||
);
|
||||
|
||||
let mut current_processed = 0u64;
|
||||
let mut has_error_or_cancel = false;
|
||||
let mut max_uid: Option<u32> = None;
|
||||
|
||||
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,
|
||||
current_processed,
|
||||
FolderStatus::Cancelled,
|
||||
None,
|
||||
)?;
|
||||
has_error_or_cancel = true;
|
||||
break;
|
||||
}
|
||||
|
||||
match ImapExecutor::batch_retrieve_emails(
|
||||
&mut session,
|
||||
account_id,
|
||||
mailbox_id,
|
||||
total,
|
||||
page as u64,
|
||||
page_size as u64,
|
||||
&mailbox.encoded_name(),
|
||||
account.max_email_size_bytes,
|
||||
token.clone(),
|
||||
&mut max_uid,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(count) => {
|
||||
current_processed += count as u64;
|
||||
DownloadState::update_folder_progress(
|
||||
account_id,
|
||||
mailbox.name.clone(),
|
||||
total,
|
||||
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,
|
||||
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,
|
||||
current_processed,
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
session.logout().await.ok();
|
||||
Ok(max_uid)
|
||||
}
|
||||
|
||||
/// Generates a synthetic UIDVALIDITY for IMAP servers that don't provide it.
|
||||
/// Uses a stable hash of the mailbox name to ensure consistent IDs across sessions.
|
||||
fn generate_synthetic_uidvalidity(mailbox_name: &str) -> u32 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
mailbox_name.hash(&mut hasher);
|
||||
(hasher.finish() as u32).wrapping_add(1) // Avoid 0, which might be reserved
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Handle missing UIDVALIDITY from non-compliant IMAP servers
|
||||
// (e.g., Tencent Enterprise Mail, etc.)
|
||||
let remote_uid_validity = match remote_mailbox.uid_validity {
|
||||
Some(uid) => uid,
|
||||
None => {
|
||||
// Generate a synthetic UIDVALIDITY based on mailbox name
|
||||
let synthetic_uid = generate_synthetic_uidvalidity(&remote_mailbox.name);
|
||||
|
||||
warn!(
|
||||
"Account {}: Mailbox '{}' - Server did not provide UIDVALIDITY. \
|
||||
Using synthetic UIDVALIDITY {} based on mailbox name. \
|
||||
This mailbox will be synced but may require periodic rebuilds if the server's mailbox structure changes.",
|
||||
account_id, remote_mailbox.name, synthetic_uid
|
||||
);
|
||||
|
||||
synthetic_uid
|
||||
}
|
||||
};
|
||||
|
||||
let new_highest_uid = if local_mailbox.uid_validity != Some(remote_uid_validity) {
|
||||
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_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?
|
||||
};
|
||||
|
||||
let mut updated = remote_mailbox.clone();
|
||||
updated.highest_uid = new_highest_uid;
|
||||
// Update uid_validity with the resolved value (either from server or synthetic)
|
||||
if updated.uid_validity.is_none() {
|
||||
updated.uid_validity = Some(remote_uid_validity);
|
||||
}
|
||||
mailboxes_to_update.push(updated);
|
||||
}
|
||||
//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(new_highest_uid) => {
|
||||
let mut updated = mailbox.clone();
|
||||
updated.highest_uid = new_highest_uid;
|
||||
MailBox::batch_upsert(&[updated])?;
|
||||
}
|
||||
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
|
||||
/// Incrementally syncs a mailbox.
|
||||
/// Returns the new highest UID after sync, or `None` if nothing changed.
|
||||
async fn perform_incremental_sync(
|
||||
account: &AccountModel,
|
||||
local_mailbox: &MailBox,
|
||||
remote_mailbox: &MailBox,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<Option<u32>> {
|
||||
if remote_mailbox.exists > 0 {
|
||||
// Use stored highest_uid if available; otherwise fall back to Tantivy
|
||||
// query once (backward compatibility with pre-existing databases).
|
||||
let start_uid = match local_mailbox.highest_uid {
|
||||
Some(uid) => {
|
||||
tracing::info!(
|
||||
"[account {}][mailbox {}] perform_incremental_sync: stored highest_uid={}, remote.exists={}",
|
||||
account.id,
|
||||
local_mailbox.name,
|
||||
uid,
|
||||
remote_mailbox.exists
|
||||
);
|
||||
uid as u64 + 1
|
||||
}
|
||||
None => {
|
||||
let local_max_uid =
|
||||
ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
|
||||
tracing::info!(
|
||||
"[account {}][mailbox {}] perform_incremental_sync: highest_uid unset, Tantivy max_uid={:?}, remote.exists={}",
|
||||
account.id,
|
||||
local_mailbox.name,
|
||||
local_max_uid,
|
||||
remote_mailbox.exists
|
||||
);
|
||||
match local_max_uid {
|
||||
Some(uid) => uid + 1,
|
||||
None => {
|
||||
info!(
|
||||
"No maximum UID found in index for mailbox, assuming local storage is missing."
|
||||
);
|
||||
|
||||
let result = 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 => match &account.date_before {
|
||||
Some(r) => {
|
||||
fetch_and_save_by_date(
|
||||
account,
|
||||
&r.calculate_date()?,
|
||||
remote_mailbox,
|
||||
FetchDirection::Before,
|
||||
token,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
fetch_and_save_full_mailbox(
|
||||
account, remote_mailbox, token,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
},
|
||||
};
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut session = ImapExecutor::create_connection(account.id).await?;
|
||||
let before_date = account
|
||||
.date_before
|
||||
.as_ref()
|
||||
.map(|r| r.calculate_date())
|
||||
.transpose()?;
|
||||
|
||||
let new_max_uid = ImapExecutor::fetch_new_mail(
|
||||
&mut session,
|
||||
account,
|
||||
local_mailbox,
|
||||
start_uid,
|
||||
before_date.as_deref(),
|
||||
token,
|
||||
)
|
||||
.await?;
|
||||
session.logout().await.ok();
|
||||
|
||||
// Keep existing highest_uid if no new mail was fetched.
|
||||
Ok(new_max_uid.or(local_mailbox.highest_uid))
|
||||
} else {
|
||||
Ok(local_mailbox.highest_uid)
|
||||
}
|
||||
}
|
||||
+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(())
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
//
|
||||
// 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,
|
||||
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},
|
||||
raise_error,
|
||||
store::tantivy::{attachment::ATTACHMENT_MANAGER, 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(new_highest_uid) => {
|
||||
let mut updated = mailbox.clone();
|
||||
updated.highest_uid = new_highest_uid;
|
||||
MailBox::batch_upsert(&[updated])?;
|
||||
}
|
||||
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(new_highest_uid) => {
|
||||
let mut updated = mailbox.clone();
|
||||
updated.highest_uid = new_highest_uid;
|
||||
MailBox::batch_upsert(&[updated])?;
|
||||
}
|
||||
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<Option<u32>> {
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
|
||||
.await?;
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_mailbox_attachments(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(None);
|
||||
}
|
||||
|
||||
let result = fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn rebuild_mailbox_cache_by_date(
|
||||
account: &AccountModel,
|
||||
local_mailbox_id: u64,
|
||||
date: &str,
|
||||
remote: &MailBox,
|
||||
direction: FetchDirection,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<Option<u32>> {
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
|
||||
.await?;
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_mailbox_attachments(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(None);
|
||||
}
|
||||
|
||||
let result = fetch_and_save_by_date(account, date, remote, direction, token).await?;
|
||||
Ok(result)
|
||||
}
|
||||
+46
-65
@@ -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,32 +17,24 @@
|
||||
// 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::{
|
||||
async_find_impl, batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_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
|
||||
@@ -64,6 +56,19 @@ pub struct MailBox {
|
||||
/// 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>,
|
||||
/// The highest UID that has been successfully downloaded and stored locally.
|
||||
/// Used for incremental sync: next fetch starts from `highest_uid + 1`.
|
||||
/// If `None`, a fallback query against the Tantivy index will be performed once.
|
||||
pub highest_uid: Option<u32>,
|
||||
}
|
||||
|
||||
impl MemDbModel for MailBox {
|
||||
fn collection() -> &'static str {
|
||||
"mailboxes"
|
||||
}
|
||||
fn key(&self) -> String {
|
||||
self.id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl MailBox {
|
||||
@@ -71,26 +76,8 @@ impl MailBox {
|
||||
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) -> BichonResult<MailBox> {
|
||||
let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), 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),
|
||||
@@ -99,47 +86,40 @@ impl MailBox {
|
||||
})?)
|
||||
}
|
||||
|
||||
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 fn delete(id: u64) -> BichonResult<()> {
|
||||
delete_impl::<MailBox>(DB_MANAGER.db(), &id.to_string())
|
||||
}
|
||||
|
||||
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 list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
|
||||
filter_impl::<MailBox, _>(DB_MANAGER.db(), move |m| m.account_id == account_id)
|
||||
}
|
||||
|
||||
pub async fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
||||
batch_insert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).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 async fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
||||
batch_upsert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
|
||||
pub fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
|
||||
batch_insert_impl(DB_MANAGER.db(), mailboxes.to_vec())
|
||||
}
|
||||
|
||||
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 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>,
|
||||
@@ -151,7 +131,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,227 @@
|
||||
//
|
||||
// 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)?;
|
||||
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))?;
|
||||
|
||||
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,60 @@
|
||||
//
|
||||
// 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::Batch { max_ops: 100 })
|
||||
.expect("Failed to open memdb database");
|
||||
|
||||
// Start periodic snapshot worker (every 5 minutes)
|
||||
db.start_snapshot_worker(Duration::from_secs(300));
|
||||
|
||||
// Start periodic flush worker (every 10 seconds) so buffered writes
|
||||
// are flushed regularly and not only at the batch threshold.
|
||||
db.start_flush_worker(Duration::from_secs(10));
|
||||
|
||||
DatabaseManager { db }
|
||||
}
|
||||
|
||||
/// Get a reference to the MemDb instance.
|
||||
pub fn db(&self) -> &MemDb {
|
||||
&self.db
|
||||
}
|
||||
|
||||
/// Flush any buffered WAL entries to disk. Must be called before shutdown
|
||||
/// to avoid losing writes that haven't hit the batch threshold yet.
|
||||
pub fn flush(&self) {
|
||||
if let Err(e) = self.db.flush() {
|
||||
eprintln!("[memdb] flush error on shutdown: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,838 @@
|
||||
//
|
||||
// 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::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::imap::executor::ImapExecutor;
|
||||
use crate::message::content::AttachmentInfo;
|
||||
use crate::store::blob::{DetachedEmail, BLOB_MANAGER};
|
||||
use crate::store::tantivy::attachment::ATTACHMENT_MANAGER;
|
||||
use crate::store::tantivy::dedup_cache::DEDUP_CACHE;
|
||||
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 = match fetch.body() {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
uid = fetch.uid,
|
||||
"FETCH response has no body, skipping message"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
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<()> {
|
||||
//The content hash of the original raw EML
|
||||
let email_content_hash = compute_content_hash(body);
|
||||
if DEDUP_CACHE.contains(account_id, mailbox_id, &email_content_hash) {
|
||||
tracing::debug!("Duplicate email detected");
|
||||
//println!("Duplicate email detected");
|
||||
return Ok(());
|
||||
}
|
||||
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| {
|
||||
let has_text = a.extracted_text.is_some();
|
||||
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: a.extracted_text.clone(),
|
||||
has_text,
|
||||
is_ocr: a.extracted_is_ocr,
|
||||
page_count: a.extracted_page_count.map(|n| n as u64),
|
||||
is_indexed: has_text,
|
||||
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.clone(),
|
||||
};
|
||||
// 'attachments' contains both regular and inline attachments
|
||||
let ea = EnvelopeWithAttachments {
|
||||
envelope,
|
||||
attachments: Some(attachments),
|
||||
};
|
||||
let doc = ea.to_document(&body_text, 0)?;
|
||||
tracing::debug!(
|
||||
"[account {}][mailbox {}] extract: uid={} msg_id={} content_hash={}",
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
&ea.envelope.message_id,
|
||||
&ea.envelope.content_hash,
|
||||
);
|
||||
ENVELOPE_MANAGER.queue(doc).await;
|
||||
DEDUP_CACHE.insert(account_id, mailbox_id, &email_content_hash);
|
||||
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());
|
||||
|
||||
// Collect candidates for text extraction (non-inline, known document types).
|
||||
struct TextCandidate {
|
||||
content_hash: String,
|
||||
file_type: String,
|
||||
ext: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
let mut text_candidates: Vec<TextCandidate> = Vec::new();
|
||||
|
||||
for (raw_start, raw_end, att) in ranges {
|
||||
// mail-parser may report attachment offsets past the body end for
|
||||
// malformed messages; clamp the range to avoid a slice panic.
|
||||
let body_len = original_body.len();
|
||||
let raw_start = raw_start.min(body_len);
|
||||
let raw_end = raw_end.min(body_len);
|
||||
let range_valid = raw_start < raw_end;
|
||||
|
||||
// content hash is computed from the decoded attachment contents,
|
||||
// which is always available regardless of raw offset validity.
|
||||
let content_hash = compute_content_hash(att.contents());
|
||||
|
||||
if range_valid {
|
||||
let raw_bytes = &original_body[raw_start..raw_end];
|
||||
// The actual content stored in the blob is the raw undecoded data.
|
||||
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));
|
||||
|
||||
// Replace raw attachment content with a hash-based placeholder
|
||||
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
|
||||
stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned());
|
||||
} else {
|
||||
// Invalid range: store a zero-length blob so the consistency
|
||||
// check passes; reattachment will log a warning for the missing
|
||||
// blob data but won't panic.
|
||||
attachments.push((content_hash.clone(), Bytes::new()));
|
||||
}
|
||||
|
||||
let inline = att
|
||||
.content_disposition()
|
||||
.map(|d| d.is_inline())
|
||||
.unwrap_or_else(|| att.content_id().is_some());
|
||||
let 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());
|
||||
let has_cid = att.content_id().is_some();
|
||||
let ext = att
|
||||
.attachment_name()
|
||||
.and_then(|n| {
|
||||
std::path::Path::new(&n)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if !inline || !has_cid {
|
||||
let decoded_len = att.contents().len();
|
||||
if decoded_len <= crate::ext::text_extractor::MAX_EXTRACT_BYTES
|
||||
&& crate::ext::text_extractor::should_try_extract(&file_type, &ext)
|
||||
{
|
||||
text_candidates.push(TextCandidate {
|
||||
content_hash: content_hash.clone(),
|
||||
file_type: file_type.clone(),
|
||||
ext: ext.clone(),
|
||||
bytes: att.contents().to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let info = AttachmentInfo {
|
||||
filename: att.attachment_name().map(|n| n.to_string()),
|
||||
size: att.contents().len(),
|
||||
inline,
|
||||
file_type,
|
||||
content_id: att.content_id().map(|id| id.to_string()),
|
||||
content_hash: content_hash.clone(),
|
||||
is_message: att.is_message(),
|
||||
extracted_text: None,
|
||||
extracted_page_count: None,
|
||||
extracted_is_ocr: false,
|
||||
};
|
||||
|
||||
attachment_infos.push(info);
|
||||
}
|
||||
|
||||
// Run text extraction in a single spawn_blocking batch.
|
||||
if !text_candidates.is_empty() {
|
||||
if let Ok(mut extracted_map) = tokio::task::spawn_blocking(move || {
|
||||
let mut map: std::collections::HashMap<
|
||||
String,
|
||||
(String, Option<u32>, bool),
|
||||
> = std::collections::HashMap::new();
|
||||
for c in text_candidates {
|
||||
if let Some(r) =
|
||||
crate::ext::text_extractor::extract_text(&c.file_type, &c.ext, &c.bytes)
|
||||
{
|
||||
map.insert(c.content_hash, (r.text, r.page_count, r.is_ocr));
|
||||
}
|
||||
}
|
||||
map
|
||||
})
|
||||
.await
|
||||
{
|
||||
for info in &mut attachment_infos {
|
||||
if let Some((text, pages, is_ocr)) = extracted_map.remove(&info.content_hash) {
|
||||
info.extracted_text = Some(text);
|
||||
info.extracted_page_count = pages;
|
||||
info.extracted_is_ocr = is_ocr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)))
|
||||
}
|
||||
|
||||
/// Returns the raw EML for an indexed message, self-healing a missing content blob.
|
||||
///
|
||||
/// Behaves like [`reattach_eml_content`], but when the message's content blob is
|
||||
/// absent from the blob store it fetches that single message on demand from the
|
||||
/// IMAP server (`UID FETCH <uid> (BODY.PEEK[])`), persists it for future requests,
|
||||
/// and returns it. If the on-demand fetch itself fails, the original "content not
|
||||
/// found" error from [`reattach_eml_content`] is surfaced unchanged so the caller
|
||||
/// still produces its 404.
|
||||
pub async fn reattach_eml_content_self_healing(
|
||||
account_id: u64,
|
||||
envelope_id: String,
|
||||
) -> BichonResult<(Envelope, Bytes)> {
|
||||
let envelope = 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
|
||||
)
|
||||
})?
|
||||
.envelope;
|
||||
|
||||
// Fast path: the content blob is present, reuse the regular reattach logic.
|
||||
if BLOB_MANAGER.get_email(&envelope.content_hash)?.is_some() {
|
||||
return reattach_eml_content(account_id, envelope_id);
|
||||
}
|
||||
|
||||
// The blob is missing. Try to recover it directly from the IMAP server.
|
||||
match recover_message_blob(&envelope).await {
|
||||
Ok(raw_body) => {
|
||||
tracing::info!(
|
||||
account_id,
|
||||
envelope_id = %envelope_id,
|
||||
uid = envelope.uid,
|
||||
"Self-healed missing email content blob via on-demand IMAP fetch"
|
||||
);
|
||||
Ok((envelope, raw_body))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
envelope_id = %envelope_id,
|
||||
uid = envelope.uid,
|
||||
error = %e,
|
||||
"On-demand IMAP fetch for missing content blob failed; returning not-found"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches one message from IMAP and re-stores its detached blob.
|
||||
///
|
||||
/// On success the freshly fetched raw RFC822 body is returned; it is also queued
|
||||
/// (in detached form) into the blob store so subsequent requests hit the cache.
|
||||
/// Fails if the message cannot be fetched, or if the fetched bytes do not match
|
||||
/// the archived `content_hash` (the server-side message no longer matches what
|
||||
/// Bichon archived, so it cannot be treated as a recovery of that blob).
|
||||
async fn recover_message_blob(envelope: &Envelope) -> BichonResult<Bytes> {
|
||||
let mailbox = MailBox::find_mailbox(envelope.account_id, envelope.mailbox_id)?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Mailbox not found: account_id={} mailbox_id={}",
|
||||
envelope.account_id, envelope.mailbox_id
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut session = ImapExecutor::create_connection(envelope.account_id).await?;
|
||||
let result = ImapExecutor::fetch_single_message_body(
|
||||
&mut session,
|
||||
&mailbox.encoded_name(),
|
||||
envelope.uid,
|
||||
)
|
||||
.await;
|
||||
session.logout().await.ok();
|
||||
let raw_body = result?;
|
||||
|
||||
let fetched_hash = compute_content_hash(&raw_body);
|
||||
if fetched_hash != envelope.content_hash {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Fetched message does not match archived content: expected content_hash={} got={}",
|
||||
envelope.content_hash, fetched_hash
|
||||
),
|
||||
ErrorCode::ImapUnexpectedResult
|
||||
));
|
||||
}
|
||||
|
||||
// Re-create the detached blob (stripped EML + attachments) so the missing
|
||||
// blob is repopulated for future requests. The detached EML is queued under
|
||||
// `fetched_hash`, which equals `envelope.content_hash`.
|
||||
let message = MessageParser::new().parse(raw_body.as_slice()).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Failed to parse fetched email content".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
detach_and_store_attachments(&raw_body, &message, &fetched_hash).await;
|
||||
|
||||
Ok(Bytes::from(raw_body))
|
||||
}
|
||||
|
||||
#[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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies that [`super::detach_and_store_attachments`] does not panic
|
||||
/// when mail-parser reports attachment offsets past the raw body length.
|
||||
///
|
||||
/// Regression test for: "range end index X out of range for slice of
|
||||
/// length Y" panic caused by a malformed email whose attachment
|
||||
/// `raw_end_offset` exceeded the actual body size.
|
||||
#[tokio::test]
|
||||
async fn detach_attachments_bounds_check() {
|
||||
let raw = concat!(
|
||||
"From: sender@example.com\r\n",
|
||||
"To: recipient@example.com\r\n",
|
||||
"Subject: Test\r\n",
|
||||
"MIME-Version: 1.0\r\n",
|
||||
"Content-Type: multipart/mixed; boundary=\"bnd\"\r\n",
|
||||
"\r\n",
|
||||
"--bnd\r\n",
|
||||
"Content-Type: text/plain\r\n",
|
||||
"\r\n",
|
||||
"Hello\r\n",
|
||||
"--bnd\r\n",
|
||||
"Content-Type: application/octet-stream\r\n",
|
||||
"Content-Disposition: attachment; filename=\"test.bin\"\r\n",
|
||||
"\r\n",
|
||||
"AAAAABBBBBCCCCCDDDDDEEEEEAAAAABBBBBCCCCCDDDDDEEEEE\r\n",
|
||||
"--bnd--\r\n",
|
||||
)
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
|
||||
let message = mail_parser::MessageParser::new()
|
||||
.parse(&raw)
|
||||
.expect("parse valid MIME message");
|
||||
assert_eq!(message.attachment_count(), 1);
|
||||
|
||||
// Truncate the raw body so the attachment's raw_end_offset lies
|
||||
// past the body end — exactly the scenario reported by users.
|
||||
let truncated = &raw[..raw.len() - 20];
|
||||
assert!(truncated.len() < raw.len());
|
||||
|
||||
// Must not panic.
|
||||
let infos = super::detach_and_store_attachments(
|
||||
truncated,
|
||||
&message,
|
||||
"test_content_hash",
|
||||
)
|
||||
.await;
|
||||
|
||||
// The attachment count must still match so the consistency check
|
||||
// in reattach_eml_content doesn't fail later.
|
||||
assert_eq!(infos.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//
|
||||
@@ -17,4 +17,5 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
pub mod extractor;
|
||||
pub mod meta;
|
||||
pub mod utils;
|
||||
@@ -1,3 +1,21 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use mail_parser::parsers::MessageStream;
|
||||
use regex::{Captures, Regex};
|
||||
|
||||
@@ -72,43 +90,86 @@ pub fn normalize_subject(raw_subject: Option<&str>) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::modules::envelope::utils::merge_contiguous_encoded_words;
|
||||
use crate::envelope::utils::{merge_contiguous_encoded_words, normalize_subject};
|
||||
|
||||
// ── merge_contiguous_encoded_words ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test3() {
|
||||
#[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?=");
|
||||
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>;
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
// Event bus extension point.
|
||||
//
|
||||
// Community edition: NoopEventBus — all events are discarded.
|
||||
// Pro edition: AuditEventBus — events are persisted to audit database.
|
||||
// Enterprise edition: adds SIEM webhook to the same trait impl.
|
||||
//
|
||||
// The open-source server emits events at key points (login, view, delete, search).
|
||||
// It never reads from the event bus — events are fire-and-forget.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
EmailViewed {
|
||||
email_id: String,
|
||||
user: String,
|
||||
ip: IpAddr,
|
||||
},
|
||||
EmailDeleted {
|
||||
email_id: String,
|
||||
user: String,
|
||||
},
|
||||
UserLoggedIn {
|
||||
user: String,
|
||||
ip: IpAddr,
|
||||
},
|
||||
UserCreated {
|
||||
created_by: String,
|
||||
new_user: String,
|
||||
},
|
||||
SearchPerformed {
|
||||
query: String,
|
||||
user: String,
|
||||
},
|
||||
SettingsChanged {
|
||||
key: String,
|
||||
user: String,
|
||||
},
|
||||
AttachmentDownloaded {
|
||||
email_id: String,
|
||||
content_hash: String,
|
||||
user: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait EventBus: Send + Sync {
|
||||
fn emit(&self, event: Event);
|
||||
}
|
||||
|
||||
/// Default — all events are discarded.
|
||||
struct NoopEventBus;
|
||||
impl EventBus for NoopEventBus {
|
||||
fn emit(&self, _event: Event) {}
|
||||
}
|
||||
|
||||
static EVENT_BUS: LazyLock<RwLock<Box<dyn EventBus>>> =
|
||||
LazyLock::new(|| RwLock::new(Box::new(NoopEventBus)));
|
||||
|
||||
/// Called by Pro/Enterprise at startup to replace the noop default.
|
||||
pub fn set_event_bus(bus: Box<dyn EventBus>) {
|
||||
*EVENT_BUS.write().unwrap() = bus;
|
||||
}
|
||||
|
||||
/// Fire-and-forget. Called by the server at key points.
|
||||
pub fn emit(event: Event) {
|
||||
EVENT_BUS.read().unwrap().emit(event);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// Event bus extension point.
|
||||
//
|
||||
// Community edition: NoopEventBus — all events are discarded.
|
||||
// Pro edition: AuditEventBus — events are persisted to audit database.
|
||||
// Enterprise edition: adds SIEM webhook to the same trait impl.
|
||||
//
|
||||
// The open-source server emits events at key points (login, view, delete, search).
|
||||
// It never reads from the event bus — events are fire-and-forget.
|
||||
|
||||
pub mod event_bus;
|
||||
pub mod text_extractor;
|
||||
@@ -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/>.
|
||||
|
||||
// Attachment text extraction extension point.
|
||||
//
|
||||
// Community edition: NoopExtractor — no attachments are text-indexed.
|
||||
// Pro edition: PdfExtractor — extracts text from PDF, Word, etc.
|
||||
//
|
||||
// Used in: crates/core/src/envelope/extractor.rs
|
||||
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
|
||||
pub struct ExtractedText {
|
||||
pub text: String,
|
||||
pub page_count: Option<u32>,
|
||||
pub is_ocr: bool,
|
||||
}
|
||||
|
||||
pub trait AttachmentTextExtractor: Send + Sync {
|
||||
/// Returns None if this extractor doesn't handle the file type.
|
||||
/// Returns Some(ExtractedText) if text was successfully extracted.
|
||||
fn extract(&self, content_type: &str, ext: &str, bytes: &[u8]) -> Option<ExtractedText>;
|
||||
}
|
||||
|
||||
/// Default — all attachments are skipped.
|
||||
struct NoopExtractor;
|
||||
impl AttachmentTextExtractor for NoopExtractor {
|
||||
fn extract(&self, _ct: &str, _ext: &str, _bytes: &[u8]) -> Option<ExtractedText> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
static EXTRACTOR: LazyLock<RwLock<Box<dyn AttachmentTextExtractor>>> =
|
||||
LazyLock::new(|| RwLock::new(Box::new(NoopExtractor)));
|
||||
|
||||
/// Called by Pro/Enterprise at startup to replace the noop default.
|
||||
pub fn set_extractor(extractor: Box<dyn AttachmentTextExtractor>) {
|
||||
*EXTRACTOR.write().unwrap() = extractor;
|
||||
}
|
||||
|
||||
/// Attachments larger than this are skipped (10 MiB). Avoids excessive memory
|
||||
/// and CPU cost for huge files whose text is rarely useful for search.
|
||||
pub const MAX_EXTRACT_BYTES: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// Quick pre-filter: returns true for file types where text extraction may
|
||||
/// produce useful results. Avoids cloning attachment bytes for images, videos,
|
||||
/// archives, etc. when no registered extractor would handle them.
|
||||
pub fn should_try_extract(content_type: &str, ext: &str) -> bool {
|
||||
matches!(
|
||||
ext,
|
||||
"pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
|
||||
| "txt" | "rtf" | "odt" | "ods" | "odp"
|
||||
) || content_type.starts_with("text/")
|
||||
}
|
||||
|
||||
/// Called by the attachment pipeline during IMAP sync.
|
||||
/// The caller should wrap this in spawn_blocking for CPU-bound extraction.
|
||||
pub fn extract_text(content_type: &str, ext: &str, bytes: &[u8]) -> Option<ExtractedText> {
|
||||
EXTRACTOR.read().unwrap().extract(content_type, ext, bytes)
|
||||
}
|
||||
@@ -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,654 @@
|
||||
//
|
||||
// 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, DownloadStatus, FolderStatus};
|
||||
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[])";
|
||||
const SIZE_ONLY_FETCH: &str = "(UID RFC822.SIZE)";
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/// Fetches new mail for a mailbox.
|
||||
///
|
||||
/// When `before` is `Some(date)`, a two-step approach is used:
|
||||
/// `UID SEARCH` to find matching UIDs (standard IMAP), then batch `UID FETCH`
|
||||
/// for the specific UIDs. When `before` is `None`, a direct ranged
|
||||
/// `UID FETCH {start}:*` is issued and results are streamed.
|
||||
///
|
||||
/// Returns `Ok(Some(max_uid))` with the highest UID fetched, or `Ok(None)`
|
||||
/// if no new mail was found.
|
||||
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<Option<u32>> {
|
||||
assert!(start_uid > 0, "start_uid must be greater than 0");
|
||||
|
||||
session
|
||||
.examine(&mailbox.encoded_name())
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
|
||||
match before {
|
||||
Some(date) => {
|
||||
Self::fetch_new_mail_with_before(session, account, mailbox, start_uid, date, token)
|
||||
.await
|
||||
}
|
||||
None => Self::fetch_new_mail_range(session, account, mailbox, start_uid, token).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-step approach for date-filtered incremental fetch: UID SEARCH first,
|
||||
/// then batch UID FETCH for matching UIDs. Uses standard IMAP syntax that
|
||||
/// works across all compliant servers.
|
||||
async fn fetch_new_mail_with_before(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
start_uid: u64,
|
||||
date: &str,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<Option<u32>> {
|
||||
let query = format!("UID {start_uid}:* BEFORE {date}");
|
||||
info!(
|
||||
"[account {}][mailbox {}] fetch_new_mail: UID SEARCH {}",
|
||||
account.id, mailbox.name, query
|
||||
);
|
||||
let results = session.uid_search(&query).await.map_err(|e| {
|
||||
let err_msg = format!("UID SEARCH failed in [{}]: {:#?}", mailbox.name, e);
|
||||
let _ = DownloadState::append_session_error(account.id, err_msg);
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
|
||||
if results.is_empty() {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Success,
|
||||
Some("No new emails found.".into()),
|
||||
)?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut uid_vec: Vec<u32> = results.into_iter().collect();
|
||||
uid_vec.sort();
|
||||
let max_uid = uid_vec.last().copied();
|
||||
let planned = uid_vec.len() as u64;
|
||||
let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
|
||||
let uid_batches = generate_uid_sequence_hashset(uid_vec, batch_size);
|
||||
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
planned,
|
||||
0,
|
||||
FolderStatus::Pending,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let mut count = 0u64;
|
||||
for batch in uid_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(),
|
||||
planned,
|
||||
count,
|
||||
FolderStatus::Cancelled,
|
||||
None,
|
||||
)?;
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
let processed = Self::uid_batch_retrieve_emails(
|
||||
session,
|
||||
account.id,
|
||||
mailbox.id,
|
||||
&batch.0,
|
||||
account.max_email_size_bytes,
|
||||
token.clone(),
|
||||
)
|
||||
.await?;
|
||||
count += processed;
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
planned,
|
||||
count,
|
||||
FolderStatus::Downloading,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
count,
|
||||
count,
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok(max_uid)
|
||||
}
|
||||
|
||||
/// Direct ranged UID FETCH without date filtering. Streams results from
|
||||
/// the server in a single IMAP round-trip.
|
||||
async fn fetch_new_mail_range(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
start_uid: u64,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<Option<u32>> {
|
||||
let uid_range = format!("{start_uid}:*");
|
||||
info!(
|
||||
"[account {}][mailbox {}] fetch_new_mail: direct UID FETCH {}",
|
||||
account.id, mailbox.name, uid_range
|
||||
);
|
||||
|
||||
let mut stream = session
|
||||
.uid_fetch(&uid_range, BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err_msg = format!("UID FETCH failed in [{}]: {:#?}", mailbox.name, e);
|
||||
let _ = DownloadState::append_session_error(account.id, err_msg);
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
|
||||
let mut count = 0u64;
|
||||
let mut skipped = 0u64;
|
||||
let mut max_uid: Option<u32> = None;
|
||||
let size_limit = account.max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
while let Some(fetch) = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
tracing::info!("Account {}: fetch_new_mail stream interrupted.", account.id);
|
||||
DownloadState::update_session_status(
|
||||
account.id,
|
||||
DownloadStatus::Cancelled,
|
||||
Some("User stopped or system shutdown".to_string()),
|
||||
)?;
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
|
||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||
if msg_size > 0 && msg_size > size_limit {
|
||||
tracing::warn!(
|
||||
account_id = account.id,
|
||||
mailbox_id = mailbox.id,
|
||||
uid = fetch.uid,
|
||||
size = msg_size,
|
||||
limit = size_limit,
|
||||
"Skipping oversized email (streaming mode)"
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(uid) = fetch.uid {
|
||||
max_uid = Some(max_uid.unwrap_or(0).max(uid));
|
||||
}
|
||||
extract_envelope_and_store_it(fetch, account.id, mailbox.id).await?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
let total = count + skipped;
|
||||
if total == 0 {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Success,
|
||||
Some("No new emails found.".into()),
|
||||
)?;
|
||||
} else {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
total,
|
||||
count,
|
||||
FolderStatus::Success,
|
||||
if skipped > 0 {
|
||||
Some(format!("{skipped} email(s) skipped due to size limit"))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(max_uid)
|
||||
}
|
||||
|
||||
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,
|
||||
max_email_size_bytes: Option<u64>,
|
||||
token: CancellationToken,
|
||||
max_uid: &mut Option<u32>,
|
||||
) -> BichonResult<usize> {
|
||||
assert!(page > 0, "Page number must be greater than 0");
|
||||
assert!(page_size > 0, "Page size must be greater than 0");
|
||||
|
||||
// Fetch messages starting from the oldest (ascending order).
|
||||
let start = (page - 1) * page_size + 1;
|
||||
if start > total {
|
||||
return Ok(0);
|
||||
}
|
||||
let end = (start + page_size - 1).min(total);
|
||||
|
||||
let sequence_set = format!("{}:{}", start, end);
|
||||
info!(
|
||||
"Fetching mailbox '{}' messages: sequence {} (page {}, page_size {})",
|
||||
encoded_mailbox_name, sequence_set, page, page_size
|
||||
);
|
||||
|
||||
let limit = max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
|
||||
// PASS 1: fetch only SIZE to identify oversized messages
|
||||
let acceptable_uids = {
|
||||
let mut size_stream = session
|
||||
.fetch(sequence_set.as_str(), SIZE_ONLY_FETCH)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
|
||||
let mut uids: Vec<u32> = Vec::new();
|
||||
while let Some(fetch) = size_stream.try_next().await.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})? {
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||
if msg_size == 0 || msg_size <= limit {
|
||||
uids.push(uid);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
size = msg_size,
|
||||
limit,
|
||||
"Skipping oversized email"
|
||||
);
|
||||
}
|
||||
}
|
||||
uids
|
||||
};
|
||||
|
||||
if acceptable_uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// PASS 2: fetch bodies only for acceptable UIDs
|
||||
let filtered = compress_uid_list(acceptable_uids);
|
||||
let mut body_stream = session
|
||||
.uid_fetch(&filtered, BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
|
||||
let mut count = 0;
|
||||
while let Some(fetch) = body_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
|
||||
));
|
||||
}
|
||||
if let Some(uid) = fetch.uid {
|
||||
*max_uid = Some((*max_uid).unwrap_or(0).max(uid));
|
||||
}
|
||||
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,
|
||||
max_email_size_bytes: Option<u64>,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<u64> {
|
||||
let limit = max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
|
||||
// PASS 1: fetch only SIZE to identify oversized messages
|
||||
let acceptable_uids = {
|
||||
let mut size_stream = session
|
||||
.uid_fetch(uid_set, SIZE_ONLY_FETCH)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
|
||||
let mut uids: Vec<u32> = Vec::new();
|
||||
while let Some(fetch) = size_stream.try_next().await.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})? {
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||
if msg_size == 0 || msg_size <= limit {
|
||||
uids.push(uid);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
size = msg_size,
|
||||
limit,
|
||||
"Skipping oversized email"
|
||||
);
|
||||
}
|
||||
}
|
||||
uids
|
||||
};
|
||||
|
||||
if acceptable_uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// PASS 2: fetch bodies only for acceptable UIDs
|
||||
let filtered = compress_uid_list(acceptable_uids);
|
||||
let mut body_stream = session
|
||||
.uid_fetch(&filtered, BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
|
||||
let mut count = 0u64;
|
||||
while let Some(fetch) = body_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)
|
||||
}
|
||||
|
||||
/// Fetches the raw RFC822 body of a single message by UID.
|
||||
///
|
||||
/// Selects (read-only) the given mailbox and issues `UID FETCH <uid> (BODY.PEEK[])`.
|
||||
/// Used for on-demand self-healing when an indexed message's content blob is missing.
|
||||
/// Returns the raw bytes, or an error if the message cannot be retrieved.
|
||||
pub async fn fetch_single_message_body(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
encoded_mailbox_name: &str,
|
||||
uid: u32,
|
||||
) -> BichonResult<Vec<u8>> {
|
||||
session
|
||||
.examine(encoded_mailbox_name)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
|
||||
let mut stream = session
|
||||
.uid_fetch(uid.to_string(), BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
|
||||
let fetch = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UID {uid} not found on IMAP server"),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
let body = fetch
|
||||
.body()
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("No body returned for UID {uid}"),
|
||||
ErrorCode::ImapUnexpectedResult
|
||||
)
|
||||
})?
|
||||
.to_vec();
|
||||
|
||||
// // Drain any remaining items so the stream is fully consumed before reuse.
|
||||
// while stream
|
||||
// .try_next()
|
||||
// .await
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
// .is_some()
|
||||
// {}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub async fn create_connection(
|
||||
account_id: u64,
|
||||
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
ImapConnectionManager::build(account_id).await
|
||||
}
|
||||
}
|
||||
|
||||
pub const DEFAULT_BATCH_SIZE: u32 = 30;
|
||||
pub const DEFAULT_MAX_EMAIL_SIZE: u64 = 100 * 1024 * 1024;
|
||||
|
||||
/// Compresses a sorted list of UIDs into an IMAP sequence-set string.
|
||||
/// Consecutive UIDs become ranges (e.g. `1:5`), non-consecutive are
|
||||
/// comma-separated (e.g. `1:5,10,12:15`).
|
||||
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(",")
|
||||
}
|
||||
|
||||
/// Splits a sorted list of unique UIDs into compressed sequence-set batches.
|
||||
/// Returns `Vec<(sequence_set_string, batch_count)>`.
|
||||
pub fn generate_uid_sequence_hashset(
|
||||
unique_nums: Vec<u32>,
|
||||
chunk_size: usize,
|
||||
) -> Vec<(String, u64)> {
|
||||
assert!(!unique_nums.is_empty());
|
||||
|
||||
let mut result = Vec::new();
|
||||
let nums = unique_nums;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
// ── compress_uid_list ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn compress_empty() {
|
||||
assert_eq!(compress_uid_list(vec![]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_single_uid() {
|
||||
assert_eq!(compress_uid_list(vec![42]), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_consecutive_range() {
|
||||
assert_eq!(compress_uid_list(vec![1, 2, 3, 4, 5]), "1:5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_mixed_ranges() {
|
||||
assert_eq!(
|
||||
compress_uid_list(vec![1, 2, 3, 5, 7, 8, 9, 10]),
|
||||
"1:3,5,7:10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_gap_at_boundary() {
|
||||
assert_eq!(compress_uid_list(vec![1, 2, 4, 5]), "1:2,4:5");
|
||||
}
|
||||
|
||||
// ── generate_uid_sequence_hashset ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn batch_single_chunk() {
|
||||
let batches = generate_uid_sequence_hashset(vec![1, 2, 3], 10);
|
||||
assert_eq!(batches.len(), 1);
|
||||
assert_eq!(batches[0].0, "1:3");
|
||||
assert_eq!(batches[0].1, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiple_chunks() {
|
||||
let batches = generate_uid_sequence_hashset(vec![1, 2, 3, 4, 5], 2);
|
||||
assert_eq!(batches.len(), 3);
|
||||
assert_eq!(batches[0].0, "1:2");
|
||||
assert_eq!(batches[0].1, 2);
|
||||
assert_eq!(batches[1].0, "3:4");
|
||||
assert_eq!(batches[1].1, 2);
|
||||
assert_eq!(batches[2].0, "5");
|
||||
assert_eq!(batches[2].1, 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,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,16 +62,16 @@ impl ImapConnectionManager {
|
||||
})?;
|
||||
|
||||
let password = decrypt!(&password)?;
|
||||
client.login(&username, &password).await.map_err(|e| {
|
||||
client.login(&login_name, &password).await.map_err(|e| {
|
||||
error!(
|
||||
"IMAP password auth failed for username '{}': {}",
|
||||
username, e
|
||||
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."
|
||||
@@ -96,46 +80,36 @@ impl ImapConnectionManager {
|
||||
)
|
||||
})?;
|
||||
client
|
||||
.authenticate(OAuth2::new(username.clone(), access_token))
|
||||
.authenticate(OAuth2::new(login_name.clone(), access_token))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("IMAP OAuth2 auth failed for username '{}': {}", username, 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);
|
||||
}
|
||||
};
|
||||
@@ -143,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,42 @@
|
||||
use poem_openapi::Object;
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
//use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use 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)]
|
||||
/// Skip individual emails larger than this after decoding (100 MB).
|
||||
const MAX_SINGLE_EML_BYTES: usize = 100 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
pub struct BatchEmlRequest {
|
||||
pub account_id: u64,
|
||||
pub mail_folder: String,
|
||||
@@ -26,7 +44,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 +53,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,
|
||||
@@ -49,8 +69,8 @@ pub struct BatchEmlResult {
|
||||
pub struct ImportEmls;
|
||||
|
||||
impl ImportEmls {
|
||||
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
|
||||
let account = AccountModel::check_account_exists(request.account_id).await?;
|
||||
pub async fn do_import(mut request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
|
||||
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 +78,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 {
|
||||
@@ -85,21 +105,22 @@ impl ImportEmls {
|
||||
unseen: None,
|
||||
uid_next: None,
|
||||
uid_validity: None,
|
||||
highest_uid: None,
|
||||
};
|
||||
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() {
|
||||
let mut index: usize = 0;
|
||||
while let Some(eml_base64) = request.emls.pop() {
|
||||
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
@@ -110,12 +131,31 @@ impl ImportEmls {
|
||||
index,
|
||||
error_message: error_msg,
|
||||
});
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// eml_base64 string dropped here — frees base64 memory before parsing
|
||||
|
||||
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
|
||||
Ok(env) => env,
|
||||
if decoded.len() > MAX_SINGLE_EML_BYTES {
|
||||
let size_mb = decoded.len() as f64 / 1024.0 / 1024.0;
|
||||
let error_msg = format!(
|
||||
"Email at index {} is {:.1} MB (limit 50 MB). Skipping.",
|
||||
index, size_mb,
|
||||
);
|
||||
tracing::warn!("{}", error_msg);
|
||||
failed_details.push(FailedEmlDetail {
|
||||
index,
|
||||
error_message: error_msg,
|
||||
});
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match extract_envelope_from_eml(&decoded, account_id, mailbox_id).await {
|
||||
Ok(_) => {
|
||||
success_count += 1;
|
||||
},
|
||||
Err(e) => {
|
||||
let error_msg = format!(
|
||||
"Failed to extract envelope from EML at index {}: {:?}",
|
||||
@@ -126,27 +166,11 @@ impl ImportEmls {
|
||||
index,
|
||||
error_message: error_msg,
|
||||
});
|
||||
index += 1;
|
||||
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;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
let failed_count = failed_details.len();
|
||||
@@ -0,0 +1,25 @@
|
||||
pub mod account;
|
||||
pub mod ext;
|
||||
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,11 +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::modules::logger::file::setup_file_logger;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::logger::file::setup_file_logger;
|
||||
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;
|
||||
@@ -34,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()
|
||||
@@ -61,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,231 @@
|
||||
//
|
||||
// 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);
|
||||
// Use STATUS instead of EXAMINE: gets MESSAGES/UNSEEN/UIDNEXT/UIDVALIDITY
|
||||
// without selecting the mailbox, avoiding context switches.
|
||||
let mx = session
|
||||
.status(
|
||||
mailbox_name.as_str(),
|
||||
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
|
||||
)
|
||||
.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);
|
||||
// Use STATUS instead of EXAMINE: gets MESSAGES/UNSEEN/UIDNEXT/UIDVALIDITY
|
||||
// without selecting the mailbox, avoiding context switches.
|
||||
let mx = session
|
||||
.status(
|
||||
mailbox_name.as_str(),
|
||||
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
|
||||
)
|
||||
.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
|
||||
//
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use poem_openapi::Object;
|
||||
//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)]
|
||||
pub struct Contact {
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user