Files
bichon/crates/server/src/rest/api/import.rs
T
rustmailer 40ae2d49d4 feat: web upload supports PST, configurable MBOX/PST size limits
- Make MBOX/PST upload size limits configurable via SETTINGS
    (bichon_web_mbox_upload_limit_mb defaults to 1 GB,
     bichon_web_pst_upload_limit_mb defaults to 2 GB)
2026-06-28 09:43:50 +08:00

425 lines
15 KiB
Rust

//
// 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::time::{SystemTime, UNIX_EPOCH};
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::account::migration::AccountModel;
use bichon_core::database::manager::DB_MANAGER;
use bichon_core::database::MemDbModel;
use bichon_core::import::{
check_temp_disk_space, get_import_progress, process_uploaded_file, update_progress,
BatchEmlRequest, BatchEmlResult, ImportEmls, ImportHistory, ImportProgress, ImportStatus,
MAX_WEB_EML_BYTES,
};
use bichon_core::import::history::{save_import_history, MAX_HISTORY_PER_USER};
use bichon_core::raise_error;
use bichon_core::error::code::ErrorCode;
use bichon_core::settings::cli::SETTINGS;
use bichon_core::settings::dir::DATA_DIR_MANAGER;
use bichon_core::users::permissions::Permission;
use bichon_core::import::detect_text_file;
use bichon_core::import::FileFormat;
use futures::StreamExt;
use poem::Body;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Json, Binary};
use poem_openapi::OpenApi;
use tokio::io::AsyncWriteExt;
pub struct ImportApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Import")]
impl ImportApi {
/// Batch import one or more EML files into a specified account and mail folder.
///
/// This endpoint accepts a JSON payload containing:
/// - `account_id`: the target account to import emails into
/// - `mail_folder`: the mailbox/folder name
/// - `emls`: a list of base64-encoded .eml files
///
/// Returns a summary of the import result, including total processed, successful, and failed emails.
#[oai(path = "/import", method = "post", operation_id = "do_batch_import")]
async fn do_batch_import(
&self,
/// JSON payload with account info and EML files to import
payload: Json<BatchEmlRequest>,
context: WrappedContext,
) -> ApiResult<Json<BatchEmlResult>> {
let account_id = payload.0.account_id;
let folder = payload.0.mail_folder.clone();
context.require_permission(Some(account_id), Permission::DATA_IMPORT_BATCH)?;
let result = ImportEmls::do_import(payload.0).await?;
// Save import history
let progress = ImportProgress {
import_id: format!(
"batch_{:x}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
),
status: if result.failed == 0 {
ImportStatus::Completed
} else if result.success == 0 {
ImportStatus::Failed
} else {
ImportStatus::Completed
},
format: "eml".to_string(),
total: result.total,
success: result.success,
duplicates: result.duplicates,
failed: result.failed,
failed_details: result.failed_details.clone(),
};
save_import_history(context.user.id, account_id, &folder, &progress);
Ok(Json(result))
}
/// Upload an EML or MBOX file for import into a NoSync account.
///
/// The file is sent as the raw request body. Both `account_id` and `mail_folder`
/// must be provided as query parameters, along with the original `file_name` for
/// extension validation.
///
/// Returns an `import_id` to poll for progress via `/import-progress/:import_id`.
#[oai(path = "/upload-import", method = "post", operation_id = "upload_import")]
async fn upload_import(
&self,
/// Target account ID (must be NoSync type).
account_id: Query<u64>,
/// Target mail folder name.
mail_folder: Query<String>,
/// Original file name, used for extension validation (e.g. "export.eml").
file_name: Query<String>,
/// The raw file bytes (.eml or .mbox).
data: Binary<Body>,
context: WrappedContext,
) -> ApiResult<Json<ImportProgress>> {
let account_id = account_id.0;
context.require_permission(Some(account_id), Permission::DATA_IMPORT_BATCH)?;
// Basic account validation (fails fast)
AccountModel::check_account_exists(account_id)?;
let folder = mail_folder.0.trim().to_string();
if folder.is_empty() {
return Err(raise_error!(
"mail_folder is required.".into(),
ErrorCode::InvalidParameter
))?;
}
let file_name = file_name.0.trim().to_string();
if file_name.is_empty() {
return Err(raise_error!(
"file_name is required.".into(),
ErrorCode::InvalidParameter
))?;
}
// Validate file extension
let ext_lower = std::path::Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
let is_mbox_ext = ext_lower == "mbox";
let is_eml_ext = ext_lower == "eml";
let is_pst_ext = ext_lower == "pst";
if !is_mbox_ext && !is_eml_ext && !is_pst_ext {
return Err(raise_error!(
format!(
"Unsupported file type '.{}'. Only .eml, .mbox and .pst files are allowed.",
ext_lower
),
ErrorCode::InvalidParameter
))?;
}
// Check disk space (fail fast before streaming)
let max_mbox = SETTINGS.bichon_web_mbox_upload_limit_mb as usize * 1024 * 1024;
let max_pst = SETTINGS.bichon_web_pst_upload_limit_mb as usize * 1024 * 1024;
let min_required = if is_mbox_ext {
max_mbox
} else if is_pst_ext {
max_pst
} else {
MAX_WEB_EML_BYTES
};
let free = check_temp_disk_space()?;
if free < min_required as u64 * 2 {
let free_gb = free as f64 / 1024.0 / 1024.0 / 1024.0;
let need_gb = (min_required as f64 * 2.0) / 1024.0 / 1024.0 / 1024.0;
return Err(raise_error!(
format!(
"Insufficient disk space. Free: {:.1} GB. Need at least {:.1} GB.",
free_gb, need_gb
),
ErrorCode::InvalidParameter
))?;
}
// Stream body to temp file, enforcing size limits and validating content
let import_id = format!(
"imp_{:x}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let temp_path = DATA_DIR_MANAGER.temp_dir.join(format!("import_{}.tmp", import_id));
let (format_detected, file_len) = stream_body_to_temp(
data.0,
&temp_path,
is_mbox_ext,
is_pst_ext,
).await?;
let format = format_detected.unwrap_or_else(|| {
if is_mbox_ext {
FileFormat::Mbox
} else if is_pst_ext {
FileFormat::Pst
} else {
FileFormat::Eml
}
});
let format_str = match format {
FileFormat::Mbox => "mbox".to_string(),
FileFormat::Eml => "eml".to_string(),
FileFormat::Pst => "pst".to_string(),
};
let max_mbox = SETTINGS.bichon_web_mbox_upload_limit_mb as usize * 1024 * 1024;
let max_pst = SETTINGS.bichon_web_pst_upload_limit_mb as usize * 1024 * 1024;
let max_size = match format {
FileFormat::Mbox => max_mbox,
FileFormat::Pst => max_pst,
FileFormat::Eml => MAX_WEB_EML_BYTES,
};
if file_len > max_size {
let _ = std::fs::remove_file(&temp_path);
let max_mb = max_size as f64 / 1024.0 / 1024.0;
let actual_mb = file_len as f64 / 1024.0 / 1024.0;
return Err(raise_error!(
format!(
"File too large ({:.1} MB). Maximum for {} is {:.0} MB. Use the CLI for larger files.",
actual_mb, format_str.to_uppercase(), max_mb
),
ErrorCode::InvalidParameter
))?;
}
// Record initial progress
let initial = ImportProgress {
import_id: import_id.clone(),
status: ImportStatus::Pending,
format: format_str.clone(),
total: 0,
success: 0,
duplicates: 0,
failed: 0,
failed_details: vec![],
};
// Store initial progress so polling can find it immediately
update_progress(&import_id, initial.clone());
// Spawn background processing
let id = import_id.clone();
let folder_clone = folder.clone();
let user_id = context.user.id;
tokio::task::spawn_blocking(move || {
process_uploaded_file(&id, &temp_path, &file_name, account_id, &folder_clone, user_id);
});
Ok(Json(initial))
}
/// Poll import progress by import ID.
#[oai(
path = "/import-progress/:import_id",
method = "get",
operation_id = "get_import_progress"
)]
async fn get_import_progress(
&self,
import_id: Path<String>,
context: WrappedContext,
) -> ApiResult<Json<ImportProgress>> {
let _ = context; // progress queries don't need per-account auth
match get_import_progress(&import_id.0) {
Some(progress) => Ok(Json(progress)),
None => Err(raise_error!(
format!("Import {} not found.", import_id.0),
ErrorCode::ResourceNotFound
))?,
}
}
/// Check available disk space on the server's temp directory.
#[oai(
path = "/check-disk-space",
method = "get",
operation_id = "check_disk_space"
)]
async fn check_disk_space(&self, _context: WrappedContext) -> ApiResult<Json<u64>> {
let free = check_temp_disk_space()?;
Ok(Json(free))
}
/// List import history for the current user (latest first, up to 5 entries).
#[oai(
path = "/import-history",
method = "get",
operation_id = "list_import_history"
)]
async fn list_import_history(
&self,
context: WrappedContext,
) -> ApiResult<Json<Vec<ImportHistory>>> {
let prefix = format!("{}:", context.user.id);
let coll = DB_MANAGER.db().collection(ImportHistory::collection());
let mut entries: Vec<ImportHistory> = coll
.scan_prefix(&prefix)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Sort by created_at descending (newest first), keep at most N per user
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
entries.truncate(MAX_HISTORY_PER_USER);
Ok(Json(entries))
}
}
/// Stream a poem `Body` to a temp file while enforcing size limits and
/// validating that the content looks like a text-based email file.
///
/// PST files are binary (OLE2) — text detection is skipped for them.
///
/// Returns the detected format (if any) and the total bytes written.
async fn stream_body_to_temp(
body: Body,
temp_path: &std::path::Path,
is_mbox_ext: bool,
is_pst_ext: bool,
) -> ApiResult<(Option<FileFormat>, usize)> {
let max_mbox = SETTINGS.bichon_web_mbox_upload_limit_mb as usize * 1024 * 1024;
let max_pst = SETTINGS.bichon_web_pst_upload_limit_mb as usize * 1024 * 1024;
let max_stream = if is_mbox_ext {
max_mbox
} else if is_pst_ext {
max_pst
} else {
MAX_WEB_EML_BYTES
};
let mut file = tokio::fs::File::create(temp_path).await.map_err(|e| {
raise_error!(
format!("Failed to create temp file: {}", e),
ErrorCode::InternalError
)
})?;
let mut body_stream = body.into_bytes_stream();
let mut total: usize = 0;
let mut first_chunk: Vec<u8> = Vec::new();
let mut format_detected: Option<FileFormat> = None;
let mut text_checked = false;
while let Some(chunk_result) = body_stream.next().await {
let chunk = chunk_result.map_err(|e| {
raise_error!(
format!("Failed to read request body: {}", e),
ErrorCode::InternalError
)
})?;
total += chunk.len();
// Enforce size limit during streaming
if total > max_stream {
// Clean up partial temp file
drop(file);
let _ = tokio::fs::remove_file(temp_path).await;
let max_mb = max_stream as f64 / 1024.0 / 1024.0;
return Err(raise_error!(
format!(
"Upload exceeds maximum size of {:.0} MB. Use the CLI for larger files.",
max_mb
),
ErrorCode::InvalidParameter
))?;
}
// Accumulate first ~8 KB for format & text detection
if first_chunk.len() < 8192 {
let remaining = 8192 - first_chunk.len();
first_chunk.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
}
// Once we have enough data, validate format and text
if first_chunk.len() >= 512 && !text_checked {
text_checked = true;
format_detected = bichon_core::import::detect_format(&first_chunk, "upload");
// If extension is .eml but content looks like MBOX (or vice versa), that's OK.
// PST files are binary — skip text detection.
if !is_pst_ext && !detect_text_file(&first_chunk) {
drop(file);
let _ = tokio::fs::remove_file(temp_path).await;
return Err(raise_error!(
"The uploaded file appears to be binary (not a valid email file). Only .eml, .mbox and .pst files are accepted.".into(),
ErrorCode::InvalidParameter
))?;
}
}
file.write_all(&chunk).await.map_err(|e| {
raise_error!(
format!("Failed to write temp file: {}", e),
ErrorCode::InternalError
)
})?;
}
file.flush().await.map_err(|e| {
raise_error!(
format!("Failed to flush temp file: {}", e),
ErrorCode::InternalError
)
})?;
// If file is empty, reject
if total == 0 {
let _ = tokio::fs::remove_file(temp_path).await;
return Err(raise_error!(
"Empty file is not allowed.".into(),
ErrorCode::InvalidParameter
))?;
}
Ok((format_detected, total))
}