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:
@@ -21,4 +21,7 @@ chrono.workspace = true
|
||||
mail-send.workspace = true
|
||||
base64.workspace = true
|
||||
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;
|
||||
|
||||
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 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());
|
||||
|
||||
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
|
||||
);
|
||||
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()
|
||||
);
|
||||
|
||||
selected_acc.id
|
||||
selected_acc.clone()
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use reqwest::Client;
|
||||
|
||||
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(
|
||||
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
|
||||
}
|
||||
+39
-17
@@ -25,15 +25,16 @@ use std::fs;
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
};
|
||||
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
pub mod eml;
|
||||
pub mod export;
|
||||
pub mod mbox;
|
||||
pub mod pst;
|
||||
pub mod sender;
|
||||
pub mod thunderbird;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -124,27 +125,48 @@ async fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
let target_account_id = verify_user_and_get_account(&final_config, &theme).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 operations = &[
|
||||
"1. Import: Upload email data to Bichon",
|
||||
"2. Export: Download account data as MBOX file",
|
||||
];
|
||||
|
||||
let mode_idx = Select::with_theme(&theme)
|
||||
.with_prompt("Select import method")
|
||||
.items(import_modes)
|
||||
let op_idx = Select::with_theme(&theme)
|
||||
.with_prompt("Select operation")
|
||||
.items(operations)
|
||||
.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,
|
||||
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!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
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::sender::send_batch_request;
|
||||
use crate::BichonCtlConfig;
|
||||
use bichon_core::base64_encode_url_safe;
|
||||
use console::style;
|
||||
|
||||
@@ -23,9 +23,9 @@ use mail_send::mail_builder::headers::text::Text;
|
||||
use mail_send::mail_builder::MessageBuilder;
|
||||
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::sender::send_batch_request;
|
||||
use crate::BichonCtlConfig;
|
||||
use bichon_core::base64_encode_url_safe;
|
||||
use dialoguer::Confirm;
|
||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||
|
||||
Reference in New Issue
Block a user