mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: support export account emails to a single mbox file
This commit is contained in:
Generated
+24
-2
@@ -481,12 +481,15 @@ dependencies = [
|
|||||||
"console",
|
"console",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
"hex",
|
"hex",
|
||||||
|
"indicatif",
|
||||||
"mail-parser",
|
"mail-parser",
|
||||||
"mail-send",
|
"mail-send",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"outlook-pst",
|
"outlook-pst",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sysinfo",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
@@ -2460,6 +2463,19 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indicatif"
|
||||||
|
version = "0.18.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
|
||||||
|
dependencies = [
|
||||||
|
"console",
|
||||||
|
"portable-atomic",
|
||||||
|
"unicode-width",
|
||||||
|
"unit-prefix",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "infer"
|
name = "infer"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -4260,9 +4276,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.38"
|
version = "0.23.39"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
|
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
@@ -5746,6 +5762,12 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unit-prefix"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ async-imap = { version = "0.11.2", default-features = false, features = [
|
|||||||
"compress",
|
"compress",
|
||||||
] }
|
] }
|
||||||
webpki-roots = "1.0.7"
|
webpki-roots = "1.0.7"
|
||||||
rustls = { version = "0.23.38", default-features = false, features = ["ring"] }
|
rustls = { version = "0.23.39", default-features = false, features = ["ring"] }
|
||||||
rustls-pki-types = "1.14.0"
|
rustls-pki-types = "1.14.0"
|
||||||
tokio-io-timeout = "1.2.1"
|
tokio-io-timeout = "1.2.1"
|
||||||
semver = "1.0.28"
|
semver = "1.0.28"
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
base_url = "http://localhost:15630"
|
base_url = "http://localhost:15630"
|
||||||
api_token = "2g2viN7zi4fKU1YgY50aTjl4"
|
api_token = "WuqNC0g8yNle7CVnxcvjUwjN"
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ mail-send.workspace = true
|
|||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
codepage-strings = "1.0.2"
|
codepage-strings = "1.0.2"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
|
sysinfo.workspace = true
|
||||||
|
indicatif = "0.18.4"
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use bichon_core::{base64_encode, store::envelope::Envelope};
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
use crate::BichonCtlConfig;
|
||||||
|
|
||||||
|
pub async fn download_and_export_with_json_header(
|
||||||
|
client: &Client,
|
||||||
|
config: &BichonCtlConfig,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_metadata_header(meta: BichonMetadata) -> String {
|
||||||
|
let json_str = serde_json::to_string(&meta).ok().unwrap();
|
||||||
|
let encoded = base64_encode!(json_str);
|
||||||
|
format!("X-Bichon-Metadata: {}\r\n", encoded)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod download;
|
||||||
|
pub mod search;
|
||||||
|
pub mod sender;
|
||||||
|
pub mod stats;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use bichon_core::{
|
||||||
|
common::paginated::DataPage,
|
||||||
|
message::search::{EmailSearchFilter, EmailSearchRequest, SortBy},
|
||||||
|
store::envelope::Envelope,
|
||||||
|
};
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use crate::BichonCtlConfig;
|
||||||
|
|
||||||
|
pub async fn search_messages(
|
||||||
|
client: &Client,
|
||||||
|
config: &BichonCtlConfig,
|
||||||
|
page: u64,
|
||||||
|
page_size: u64,
|
||||||
|
) -> Option<DataPage<Envelope>> {
|
||||||
|
let url = format!("{}/api/v1/search-messages", config.base_url);
|
||||||
|
|
||||||
|
let payload = EmailSearchRequest {
|
||||||
|
filter: EmailSearchFilter::default(),
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
sort_by: Some(SortBy::DATE),
|
||||||
|
desc: Some(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
match client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", config.api_token))
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) if res.status().is_success() => match res.json::<DataPage<Envelope>>().await {
|
||||||
|
Ok(data) => Some(data),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ✘ Failed to parse search response: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(res) => {
|
||||||
|
let status = res.status();
|
||||||
|
let error_body = res.text().await.unwrap_or_default();
|
||||||
|
eprintln!(
|
||||||
|
" ✘ Failed to search messages. Status: {}\n Server error: {}",
|
||||||
|
status, error_body
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ✘ Network error performing search: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use bichon_core::account::stats::AccountStats;
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use crate::BichonCtlConfig;
|
||||||
|
|
||||||
|
pub async fn fetch_account_stats(
|
||||||
|
client: &Client,
|
||||||
|
config: &BichonCtlConfig,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,11 @@ use bichon_core::{
|
|||||||
|
|
||||||
use crate::BichonCtlConfig;
|
use crate::BichonCtlConfig;
|
||||||
|
|
||||||
pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &ColorfulTheme) -> u64 {
|
pub async fn verify_user_and_get_account(
|
||||||
|
config: &BichonCtlConfig,
|
||||||
|
theme: &ColorfulTheme,
|
||||||
|
only_nosync: bool,
|
||||||
|
) -> MinimalAccount {
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let url = format!("{}/api/v1/current-user", config.base_url);
|
let url = format!("{}/api/v1/current-user", config.base_url);
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
|||||||
println!("Welcome, {}!", style(&user.username).cyan());
|
println!("Welcome, {}!", style(&user.username).cyan());
|
||||||
|
|
||||||
let account_list_url = format!(
|
let account_list_url = format!(
|
||||||
"{}/api/v1/minimal-account-list?only_nosync=true",
|
"{}/api/v1/minimal-account-list?only_nosync={only_nosync}",
|
||||||
config.base_url
|
config.base_url
|
||||||
);
|
);
|
||||||
let acc_response = client
|
let acc_response = client
|
||||||
@@ -184,5 +188,5 @@ pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &Color
|
|||||||
style(&selected_acc.email).cyan().bold()
|
style(&selected_acc.email).cyan().bold()
|
||||||
);
|
);
|
||||||
|
|
||||||
selected_acc.id
|
selected_acc.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use reqwest::Client;
|
|||||||
|
|
||||||
use bichon_core::base64_encode_url_safe;
|
use bichon_core::base64_encode_url_safe;
|
||||||
|
|
||||||
use crate::{sender::send_batch_request, BichonCtlConfig};
|
use crate::{BichonCtlConfig, api::sender::send_batch_request};
|
||||||
|
|
||||||
pub async fn handle_eml_directory_import(
|
pub async fn handle_eml_directory_import(
|
||||||
config: &BichonCtlConfig,
|
config: &BichonCtlConfig,
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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::BichonCtlConfig;
|
||||||
|
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: &BichonCtlConfig,
|
||||||
|
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...", style("✔").green());
|
||||||
|
|
||||||
|
let pb = ProgressBar::new(stats.total_count as u64);
|
||||||
|
pb.set_style(ProgressStyle::with_template(
|
||||||
|
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}"
|
||||||
|
).unwrap());
|
||||||
|
|
||||||
|
let mut file = match tokio::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.create(true)
|
||||||
|
.open(&mbox_file)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ✘ Failed to open file '{}': {}", path.display(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let page_size = 100;
|
||||||
|
let mut current_page = 1;
|
||||||
|
let mut total_pages;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Some(batch) = search_messages(&client, config, current_page, page_size).await {
|
||||||
|
total_pages = batch.total_pages.unwrap();
|
||||||
|
|
||||||
|
println!(" → Processing page {}/{}", current_page, total_pages);
|
||||||
|
|
||||||
|
for envelope in batch.items {
|
||||||
|
let success =
|
||||||
|
download_and_export_with_json_header(&client, config, envelope, &mut file)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
eprintln!(" ✘ Failed to export an email. Aborting process...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pb.inc(1);
|
||||||
|
}
|
||||||
|
if current_page >= total_pages {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current_page += 1;
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
+29
-7
@@ -25,15 +25,16 @@ use std::fs;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::verify_user_and_get_account, eml::handle_eml_directory_import,
|
auth::verify_user_and_get_account, eml::handle_eml_directory_import,
|
||||||
mbox::handle_mbox_single_file_import, pst::handle_pst_import,
|
export::handle_account_export, mbox::handle_mbox_single_file_import, pst::handle_pst_import,
|
||||||
thunderbird::handle_thunderbird_import,
|
thunderbird::handle_thunderbird_import,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod eml;
|
pub mod eml;
|
||||||
|
pub mod export;
|
||||||
pub mod mbox;
|
pub mod mbox;
|
||||||
pub mod pst;
|
pub mod pst;
|
||||||
pub mod sender;
|
|
||||||
pub mod thunderbird;
|
pub mod thunderbird;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -124,7 +125,21 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let target_account_id = verify_user_and_get_account(&final_config, &theme).await;
|
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 = &[
|
let import_modes = &[
|
||||||
"1. EML: Scan directory recursively (Maintains folder structure)",
|
"1. EML: Scan directory recursively (Maintains folder structure)",
|
||||||
@@ -141,10 +156,17 @@ async fn main() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
match mode_idx {
|
match mode_idx {
|
||||||
0 => handle_eml_directory_import(&final_config, target_account_id, &theme).await,
|
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,
|
1 => handle_mbox_single_file_import(&final_config, target_account.id, &theme).await,
|
||||||
2 => handle_thunderbird_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,
|
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!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::api::sender::send_batch_request;
|
||||||
use crate::mbox::gmail::determine_folder;
|
use crate::mbox::gmail::determine_folder;
|
||||||
use crate::mbox::reader::MboxFile;
|
use crate::mbox::reader::MboxFile;
|
||||||
use crate::sender::send_batch_request;
|
|
||||||
use crate::BichonCtlConfig;
|
use crate::BichonCtlConfig;
|
||||||
use bichon_core::base64_encode_url_safe;
|
use bichon_core::base64_encode_url_safe;
|
||||||
use console::style;
|
use console::style;
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ use mail_send::mail_builder::headers::text::Text;
|
|||||||
use mail_send::mail_builder::MessageBuilder;
|
use mail_send::mail_builder::MessageBuilder;
|
||||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||||
|
|
||||||
use crate::BichonCtlConfig;
|
use crate::api::sender::send_batch_request;
|
||||||
use crate::pst::encoding::decode_subject;
|
use crate::pst::encoding::decode_subject;
|
||||||
use crate::sender::send_batch_request;
|
use crate::BichonCtlConfig;
|
||||||
use bichon_core::base64_encode_url_safe;
|
use bichon_core::base64_encode_url_safe;
|
||||||
use dialoguer::Confirm;
|
use dialoguer::Confirm;
|
||||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod grant;
|
pub mod grant;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
pub mod payload;
|
|
||||||
pub mod state;
|
|
||||||
pub mod since;
|
|
||||||
pub mod old_state;
|
pub mod old_state;
|
||||||
|
pub mod payload;
|
||||||
|
pub mod since;
|
||||||
|
pub mod state;
|
||||||
|
pub mod stats;
|
||||||
pub mod view;
|
pub mod view;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
@@ -21,12 +21,15 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
common::paginated::DataPage, error::{BichonResult, code::ErrorCode}, raise_error, store::{
|
common::paginated::DataPage,
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
raise_error,
|
||||||
|
store::{
|
||||||
envelope::Envelope,
|
envelope::Envelope,
|
||||||
tantivy::{
|
tantivy::{
|
||||||
attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER, model::AttachmentModel,
|
attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER, model::AttachmentModel,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -66,11 +69,11 @@ pub enum SortBy {
|
|||||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct EmailSearchRequest {
|
pub struct EmailSearchRequest {
|
||||||
filter: EmailSearchFilter,
|
pub filter: EmailSearchFilter,
|
||||||
page: u64,
|
pub page: u64,
|
||||||
page_size: u64,
|
pub page_size: u64,
|
||||||
sort_by: Option<SortBy>,
|
pub sort_by: Option<SortBy>,
|
||||||
desc: Option<bool>,
|
pub desc: Option<bool>,
|
||||||
}
|
}
|
||||||
impl EmailSearchRequest {
|
impl EmailSearchRequest {
|
||||||
pub fn validate(&self) -> BichonResult<()> {
|
pub fn validate(&self) -> BichonResult<()> {
|
||||||
|
|||||||
@@ -25,22 +25,30 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
account::migration::AccountModel, common::{paginated::DataPage, signal::SIGNAL_MANAGER}, dashboard::{DashboardStats, Group, LargestEmail, TimeBucket}, error::{BichonResult, code::ErrorCode}, message::{
|
account::{migration::AccountModel, stats::AccountStats},
|
||||||
|
common::{paginated::DataPage, signal::SIGNAL_MANAGER},
|
||||||
|
dashboard::{DashboardStats, Group, LargestEmail, TimeBucket},
|
||||||
|
error::{code::ErrorCode, BichonResult},
|
||||||
|
message::{
|
||||||
search::{EmailSearchFilter, SortBy},
|
search::{EmailSearchFilter, SortBy},
|
||||||
tags::{TagAction, TagCount, TagsRequest},
|
tags::{TagAction, TagCount, TagsRequest},
|
||||||
}, raise_error, settings::dir::DATA_DIR_MANAGER, store::{
|
},
|
||||||
|
raise_error,
|
||||||
|
settings::dir::DATA_DIR_MANAGER,
|
||||||
|
store::{
|
||||||
envelope::Envelope,
|
envelope::Envelope,
|
||||||
storage::BLOB_MANAGER,
|
storage::BLOB_MANAGER,
|
||||||
tantivy::{
|
tantivy::{
|
||||||
fatal_commit,
|
fatal_commit,
|
||||||
fields::{
|
fields::{
|
||||||
F_ACCOUNT_ID, F_DATE, F_FROM, F_REGULAR_ATTACHMENT_COUNT, F_SIZE, F_TAGS,
|
F_ACCOUNT_ID, F_DATE, F_FROM, F_ID, F_REGULAR_ATTACHMENT_COUNT, F_SIZE, F_TAGS,
|
||||||
F_THREAD_ID, F_UID,
|
F_THREAD_ID, F_UID,
|
||||||
},
|
},
|
||||||
model::{EnvelopeWithAttachments, extract_contacts},
|
model::{extract_contacts, EnvelopeWithAttachments},
|
||||||
schema::SchemaTools,
|
schema::SchemaTools,
|
||||||
},
|
},
|
||||||
}, utc_now
|
},
|
||||||
|
utc_now,
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -621,6 +629,51 @@ impl IndexManager {
|
|||||||
Ok(Self::extract_max_uid(&agg_res))
|
Ok(Self::extract_max_uid(&agg_res))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_account_stats(&self, account_id: u64) -> BichonResult<AccountStats> {
|
||||||
|
let searcher = self.create_searcher()?;
|
||||||
|
let query = self.account_query(account_id);
|
||||||
|
|
||||||
|
let agg_req: Aggregations = serde_json::from_value(json!({
|
||||||
|
"total_count": {
|
||||||
|
"value_count": {
|
||||||
|
"field": F_ID
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_size": {
|
||||||
|
"sum": {
|
||||||
|
"field": F_SIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
let collector = AggregationCollector::from_aggs(agg_req, Default::default());
|
||||||
|
let agg_res = searcher
|
||||||
|
.search(query.as_ref(), &collector)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
let mut stats = AccountStats::default();
|
||||||
|
|
||||||
|
stats.total_count = Self::extract_value_count(&agg_res, "total_count")?;
|
||||||
|
|
||||||
|
let result = agg_res.0.get("total_size").ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"missing 'total_size' aggregation result".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let AggregationResult::MetricResult(MetricResult::Sum(v)) = result {
|
||||||
|
stats.total_size = v.value.map(|v| v as u64).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
"'total_size' sum metric has no value".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_max_uid(agg_res: &AggregationResults) -> Option<u64> {
|
fn extract_max_uid(agg_res: &AggregationResults) -> Option<u64> {
|
||||||
agg_res.0.get("max_uid").and_then(|result| match result {
|
agg_res.0.get("max_uid").and_then(|result| match result {
|
||||||
AggregationResult::MetricResult(MetricResult::Max(max)) => {
|
AggregationResult::MetricResult(MetricResult::Max(max)) => {
|
||||||
@@ -1110,13 +1163,13 @@ impl IndexManager {
|
|||||||
let agg_res = searcher
|
let agg_res = searcher
|
||||||
.search(query.as_ref(), &collector)
|
.search(query.as_ref(), &collector)
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
Self::extract_thread_count(&agg_res)
|
Self::extract_value_count(&agg_res, "thread_count")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_thread_count(agg_res: &AggregationResults) -> BichonResult<u64> {
|
fn extract_value_count(agg_res: &AggregationResults, name: &str) -> BichonResult<u64> {
|
||||||
let Some(result) = agg_res.0.get("thread_count") else {
|
let Some(result) = agg_res.0.get(name) else {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
"Missing aggregation result: thread_count".into(),
|
format!("Missing aggregation result: '{}'", name),
|
||||||
ErrorCode::InternalError
|
ErrorCode::InternalError
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ use bichon_core::account::payload::{
|
|||||||
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
|
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
|
||||||
};
|
};
|
||||||
use bichon_core::account::state::DownloadState;
|
use bichon_core::account::state::DownloadState;
|
||||||
|
use bichon_core::account::stats::AccountStats;
|
||||||
use bichon_core::account::view::AccountResp;
|
use bichon_core::account::view::AccountResp;
|
||||||
use bichon_core::common::paginated::{paginate_vec, DataPage};
|
use bichon_core::common::paginated::{paginate_vec, DataPage};
|
||||||
|
use bichon_core::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||||
use bichon_core::users::permissions::Permission;
|
use bichon_core::users::permissions::Permission;
|
||||||
use bichon_core::users::UserModel;
|
use bichon_core::users::UserModel;
|
||||||
use poem_openapi::param::{Path, Query};
|
use poem_openapi::param::{Path, Query};
|
||||||
@@ -182,11 +184,11 @@ impl AccountApi {
|
|||||||
|
|
||||||
/// Get the running state of an account
|
/// Get the running state of an account
|
||||||
#[oai(
|
#[oai(
|
||||||
path = "/account-state/:account_id",
|
path = "/accounts/:account_id/download-stats",
|
||||||
method = "get",
|
method = "get",
|
||||||
operation_id = "account_state"
|
operation_id = "accounts_download_state"
|
||||||
)]
|
)]
|
||||||
async fn account_state(
|
async fn accounts_download_state(
|
||||||
&self,
|
&self,
|
||||||
/// The account ID to check state for
|
/// The account ID to check state for
|
||||||
account_id: Path<u64>,
|
account_id: Path<u64>,
|
||||||
@@ -202,6 +204,27 @@ impl AccountApi {
|
|||||||
Ok(Json(state))
|
Ok(Json(state))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the stats of an account
|
||||||
|
#[oai(
|
||||||
|
path = "/accounts/:account_id/stats",
|
||||||
|
method = "get",
|
||||||
|
operation_id = "accounts_stats"
|
||||||
|
)]
|
||||||
|
async fn accounts_stats(
|
||||||
|
&self,
|
||||||
|
/// The account ID to check state for
|
||||||
|
account_id: Path<u64>,
|
||||||
|
context: WrappedContext,
|
||||||
|
) -> ApiResult<Json<AccountStats>> {
|
||||||
|
let account_id = account_id.0;
|
||||||
|
AccountModel::check_account_exists(account_id).await?;
|
||||||
|
context
|
||||||
|
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
|
||||||
|
.await?;
|
||||||
|
let state = ENVELOPE_MANAGER.get_account_stats(account_id).await?;
|
||||||
|
Ok(Json(state))
|
||||||
|
}
|
||||||
|
|
||||||
/// Get a minimal list of active accounts for use in selectors when creating account-related resources
|
/// Get a minimal list of active accounts for use in selectors when creating account-related resources
|
||||||
///
|
///
|
||||||
/// This endpoint provides a lightweight list of accounts containing only essential information (id and name).
|
/// This endpoint provides a lightweight list of accounts containing only essential information (id and name).
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ export interface AccountModel {
|
|||||||
auto_download_new_mailboxes?: boolean;
|
auto_download_new_mailboxes?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const account_state = async (account_id: number) => {
|
export const download_state = async (account_id: number) => {
|
||||||
const response = await axiosInstance.get<DownloadState>(`api/v1/account-state/${account_id}`);
|
const response = await axiosInstance.get<DownloadState>(`api/v1/accounts/${account_id}/download-stats`);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { account_state, AccountModel, FolderProgress } from '@/api/account/api'
|
import { download_state, AccountModel, FolderProgress } from '@/api/account/api'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -134,7 +134,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
|||||||
|
|
||||||
const { data: state, isLoading } = useQuery({
|
const { data: state, isLoading } = useQuery({
|
||||||
queryKey: ['running-state', currentRow.id],
|
queryKey: ['running-state', currentRow.id],
|
||||||
queryFn: () => account_state(currentRow.id),
|
queryFn: () => download_state(currentRow.id),
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
enabled: open && !!currentRow.id,
|
enabled: open && !!currentRow.id,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user