mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
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)
This commit is contained in:
Generated
+4
-4
@@ -340,14 +340,10 @@ dependencies = [
|
||||
"bichon-core",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codepage-strings",
|
||||
"compressed-rtf",
|
||||
"console",
|
||||
"dialoguer",
|
||||
"hex",
|
||||
"indicatif",
|
||||
"mail-parser",
|
||||
"mail-send",
|
||||
"memmap2",
|
||||
"outlook-pst",
|
||||
"reqwest",
|
||||
@@ -369,6 +365,8 @@ dependencies = [
|
||||
"bytes 1.11.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codepage-strings",
|
||||
"compressed-rtf",
|
||||
"cron",
|
||||
"dashmap",
|
||||
"deunicode",
|
||||
@@ -377,6 +375,7 @@ dependencies = [
|
||||
"fjall",
|
||||
"futures",
|
||||
"governor",
|
||||
"hex",
|
||||
"hickory-resolver",
|
||||
"html2text",
|
||||
"itertools 0.15.0",
|
||||
@@ -388,6 +387,7 @@ dependencies = [
|
||||
"murmur3",
|
||||
"num_cpus",
|
||||
"oauth2",
|
||||
"outlook-pst",
|
||||
"poem-openapi",
|
||||
"quick-xml 0.40.0",
|
||||
"rand 0.10.1",
|
||||
|
||||
@@ -16,12 +16,8 @@ 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
|
||||
+3
-261
@@ -16,21 +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 chrono::{DateTime, TimeZone, Utc};
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::Input;
|
||||
use mail_send::mail_builder::headers::text::Text;
|
||||
use mail_send::mail_builder::MessageBuilder;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
use crate::api::sender::send_batch_request;
|
||||
use crate::pst::encoding::decode_subject;
|
||||
use crate::BichonCliConfig;
|
||||
use bichon_core::base64_encode_url_safe;
|
||||
use dialoguer::Confirm;
|
||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||
use bichon_core::import::pst::build_eml_base64;
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::{Confirm, Input};
|
||||
use outlook_pst::messaging::folder::Folder;
|
||||
use outlook_pst::messaging::message::{Message, MessageProperties};
|
||||
use outlook_pst::ndb::node_id::NodeId;
|
||||
use reqwest::Client;
|
||||
use std::future::Future;
|
||||
@@ -38,28 +29,6 @@ use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
|
||||
mod encoding;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailMetadata {
|
||||
pub message_id: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<Vec<String>>,
|
||||
pub cc: Option<Vec<String>>,
|
||||
pub bcc: Option<Vec<String>>,
|
||||
pub html: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EmailAttachment {
|
||||
pub name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn handle_pst_import(config: &BichonCliConfig, account_id: u64, theme: &ColorfulTheme) {
|
||||
let path_str: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter the path to your SINGLE .pst file")
|
||||
@@ -244,167 +213,6 @@ fn process_folder_recursively<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
|
||||
let properties = message.properties();
|
||||
|
||||
let mut builder = MessageBuilder::new();
|
||||
if let Some(sub) = extract_subject(properties) {
|
||||
builder = builder.subject(sub);
|
||||
}
|
||||
if let Some(mid) = extract_string_property(properties, 0x1035) {
|
||||
builder = builder.message_id(mid);
|
||||
}
|
||||
if let Some(irt) = extract_string_property(properties, 0x1042) {
|
||||
builder = builder.in_reply_to(irt);
|
||||
}
|
||||
|
||||
if let Some(refs) = extract_string_property(properties, 0x1039) {
|
||||
builder = builder.header("References", Text::new(refs));
|
||||
}
|
||||
|
||||
if let Some(cid_val) = properties.get(0x3013) {
|
||||
if let PropertyValue::Binary(bin) = cid_val {
|
||||
builder = builder.header(
|
||||
"X-Bichon-Conversation-ID",
|
||||
Text::new(hex::encode(bin.buffer())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let from = extract_string_property(properties, 0x5D01)
|
||||
.or_else(|| extract_string_property(properties, 0x5D02))
|
||||
.or_else(|| extract_string_property(properties, 0x0C1F));
|
||||
|
||||
if let Some(f) = from {
|
||||
builder = builder.from(f);
|
||||
}
|
||||
|
||||
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
|
||||
let dt = filetime_to_datetime(filetime).timestamp();
|
||||
builder = builder.date(dt);
|
||||
}
|
||||
|
||||
let (to, cc, bcc) = extract_recipients_list(&message);
|
||||
if !to.is_empty() {
|
||||
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !cc.is_empty() {
|
||||
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !bcc.is_empty() {
|
||||
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
if let Some(html) = extract_html(properties) {
|
||||
builder = builder.html_body(html);
|
||||
}
|
||||
|
||||
if let Some(text) = extract_text(properties) {
|
||||
builder = builder.text_body(text);
|
||||
}
|
||||
|
||||
if let Some(attachment_table) = message.attachment_table() {
|
||||
for row in attachment_table.rows_matrix() {
|
||||
let node_id = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
|
||||
let att_props = attachment.properties();
|
||||
let name = extract_attachment_string_property(att_props, 0x3707);
|
||||
let mime = extract_attachment_string_property(att_props, 0x370E)
|
||||
.unwrap_or_else(|| "application/octet-stream".into());
|
||||
let cid = extract_attachment_string_property(att_props, 0x3712);
|
||||
let is_inline = att_props
|
||||
.get(0x3714)
|
||||
.and_then(|val| {
|
||||
if let PropertyValue::Integer32(f) = val {
|
||||
Some(f)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|flag| (flag & 0x4) != 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
|
||||
let data = bin.buffer().to_vec();
|
||||
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
|
||||
|
||||
if is_inline && cid.is_some() {
|
||||
let content_id = cid.unwrap();
|
||||
builder = builder.inline(mime, content_id, data);
|
||||
} else {
|
||||
builder = builder.attachment(mime, file_name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match builder.write_to_vec() {
|
||||
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to generate EML: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
|
||||
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
|
||||
let nsecs = (filetime % 10_000_000) * 100;
|
||||
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
|
||||
}
|
||||
|
||||
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let mut to = Vec::new();
|
||||
let mut cc = Vec::new();
|
||||
let mut bcc = Vec::new();
|
||||
|
||||
let recipient_table = message.recipient_table();
|
||||
if let Some(recipient_table) = recipient_table {
|
||||
let context = recipient_table.context();
|
||||
for row in recipient_table.rows_matrix() {
|
||||
if let Ok(cols) = row.columns(context) {
|
||||
let mut r_type = 0;
|
||||
let mut email = String::new();
|
||||
|
||||
for (col, val) in context.columns().iter().zip(cols) {
|
||||
let prop_val = val
|
||||
.as_ref()
|
||||
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
|
||||
match col.prop_id() {
|
||||
0x0C15 => {
|
||||
if let Some(PropertyValue::Integer32(t)) = prop_val {
|
||||
r_type = t;
|
||||
}
|
||||
}
|
||||
0x39FE | 0x3003 => {
|
||||
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
|
||||
email = s;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !email.is_empty() {
|
||||
match r_type {
|
||||
1 => to.push(email),
|
||||
2 => cc.push(email),
|
||||
3 => bcc.push(email),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let receiver = extract_string_property(message.properties(), 0x0076);
|
||||
if let Some(receiver) = receiver {
|
||||
to.push(receiver);
|
||||
}
|
||||
}
|
||||
(to, cc, bcc)
|
||||
}
|
||||
|
||||
async fn send_to_bichon(
|
||||
client: &Client,
|
||||
config: &BichonCliConfig,
|
||||
@@ -414,69 +222,3 @@ async fn send_to_bichon(
|
||||
) {
|
||||
send_batch_request(client, config, account_id, folder_path, emls).await;
|
||||
}
|
||||
|
||||
fn extract_subject(props: &MessageProperties) -> Option<String> {
|
||||
props.get(0x0037).and_then(|val| decode_subject(val))
|
||||
}
|
||||
|
||||
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_attachment_string_property(
|
||||
properties: &AttachmentProperties,
|
||||
prop_id: u16,
|
||||
) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_string(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_text(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1000).and_then(extract_string).or_else(|| {
|
||||
properties.get(0x1009).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_html(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1013).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => {
|
||||
let code_page = properties
|
||||
.get(0x3FDE)
|
||||
.and_then(|v| {
|
||||
if let PropertyValue::Integer32(cpid) = v {
|
||||
Some(*cpid as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(65001);
|
||||
encoding::decode_html_body(value.buffer(), code_page)
|
||||
}
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
|
||||
for &prop_id in prop_ids {
|
||||
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
|
||||
return Some(*value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -73,3 +73,7 @@ cron = "0.15"
|
||||
quick-xml = { version = "0.40.0", features = ["serialize"] }
|
||||
hickory-resolver = "0.26.0-alpha.1"
|
||||
memmap2 = "0.9.10"
|
||||
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
|
||||
compressed-rtf = "1.0.1"
|
||||
codepage-strings = "1.0.2"
|
||||
hex.workspace = true
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
//use poem_openapi::Object;
|
||||
pub mod history;
|
||||
pub mod reader;
|
||||
pub mod pst;
|
||||
pub use history::ImportHistory;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
@@ -257,9 +258,15 @@ pub fn check_temp_disk_space() -> BichonResult<u64> {
|
||||
pub enum FileFormat {
|
||||
Eml,
|
||||
Mbox,
|
||||
Pst,
|
||||
}
|
||||
|
||||
pub fn detect_format(bytes: &[u8], file_name: &str) -> Option<FileFormat> {
|
||||
// PST files start with OLE2 compound document magic bytes
|
||||
if bytes.len() >= 8 && &bytes[..8] == b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1" {
|
||||
return Some(FileFormat::Pst);
|
||||
}
|
||||
|
||||
// MBOX files start with "From " (note the trailing space after From)
|
||||
if bytes.starts_with(b"From ") {
|
||||
// Double-check: look for a valid date after the first "From " line
|
||||
@@ -290,6 +297,8 @@ pub fn detect_format(bytes: &[u8], file_name: &str) -> Option<FileFormat> {
|
||||
Some(FileFormat::Eml)
|
||||
} else if lower.ends_with(".mbox") {
|
||||
Some(FileFormat::Mbox)
|
||||
} else if lower.ends_with(".pst") {
|
||||
Some(FileFormat::Pst)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -375,7 +384,7 @@ fn validate_import_account(account_id: u64) -> BichonResult<AccountModel> {
|
||||
}
|
||||
|
||||
/// Resolve or create a mailbox/folder for the given account.
|
||||
fn resolve_mailbox(account: &AccountModel, folder: &str) -> BichonResult<u64> {
|
||||
pub(super) fn resolve_mailbox(account: &AccountModel, folder: &str) -> BichonResult<u64> {
|
||||
match account.account_type {
|
||||
AccountType::IMAP => {
|
||||
// Shouldn't reach here (validated above), but handle gracefully
|
||||
@@ -412,6 +421,13 @@ fn resolve_mailbox(account: &AccountModel, folder: &str) -> BichonResult<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve or create a mailbox for a given account_id and folder name.
|
||||
/// Used by PST import to create per-folder mailboxes.
|
||||
pub fn resolve_mailbox_by_account_id(account_id: u64, folder: &str) -> BichonResult<u64> {
|
||||
let account = AccountModel::check_account_exists(account_id)?;
|
||||
resolve_mailbox(&account, folder)
|
||||
}
|
||||
|
||||
/// Process an uploaded file (EML or MBOX) and import into the given account/folder.
|
||||
/// This runs synchronously and should be spawned on a background thread.
|
||||
///
|
||||
@@ -497,6 +513,7 @@ pub fn process_uploaded_file(
|
||||
match format {
|
||||
FileFormat::Eml => process_eml_file(import_id, file_path, account_id, mailbox_id, user_id, folder),
|
||||
FileFormat::Mbox => process_mbox_file(import_id, file_path, account_id, mailbox_id, user_id, folder),
|
||||
FileFormat::Pst => process_pst_upload(import_id, file_path, account_id, mailbox_id, user_id, folder),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +529,7 @@ fn detect_format_from_file(file_path: &Path, file_name: &str) -> BichonResult<Fi
|
||||
|
||||
detect_format(&buf, file_name).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Unknown file format. Supported: .eml, .mbox".into(),
|
||||
"Unknown file format. Supported: .eml, .mbox, .pst".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
)
|
||||
})
|
||||
@@ -694,6 +711,111 @@ fn process_single_eml(
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a PST file uploaded via the web UI.
|
||||
/// Two-pass approach: count messages first, then process with periodic progress updates.
|
||||
fn process_pst_upload(
|
||||
import_id: &str,
|
||||
file_path: &Path,
|
||||
account_id: u64,
|
||||
_mailbox_id: u64, // ignored; PST creates its own mailboxes per folder
|
||||
user_id: u64,
|
||||
folder: &str,
|
||||
) {
|
||||
// Pass 1: count total messages
|
||||
let total = match pst::count_pst_messages(file_path) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
update_progress(import_id, ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Processing,
|
||||
format: "pst".to_string(),
|
||||
total,
|
||||
success: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
failed_details: vec![],
|
||||
});
|
||||
|
||||
// Pass 2: process messages with progress updates
|
||||
let mut success_count: usize = 0;
|
||||
let mut failed_details: Vec<FailedItemDetail> = Vec::new();
|
||||
let mut index: usize = 0;
|
||||
|
||||
let pst_store = match outlook_pst::open_store(file_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Progress callback: update progress every 50 messages
|
||||
let import_id = import_id.to_string();
|
||||
let format_str = "pst".to_string();
|
||||
pst::process_folder_with_progress(
|
||||
&ipm_subtree_folder,
|
||||
"", // parent_path starts empty
|
||||
account_id,
|
||||
total, // pass pre-counted total for accurate progress
|
||||
&mut success_count,
|
||||
&mut failed_details,
|
||||
&mut index,
|
||||
&|processed, actual_failed| {
|
||||
update_progress(&import_id, ImportProgress {
|
||||
import_id: import_id.clone(),
|
||||
status: ImportStatus::Processing,
|
||||
format: format_str.clone(),
|
||||
total,
|
||||
success: processed - actual_failed,
|
||||
duplicates: 0,
|
||||
failed: actual_failed,
|
||||
failed_details: vec![],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
|
||||
let final_progress = ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Completed,
|
||||
format: "pst".to_string(),
|
||||
total,
|
||||
success: success_count,
|
||||
duplicates: 0,
|
||||
failed: failed_details.len(),
|
||||
failed_details,
|
||||
};
|
||||
history::save_import_history(user_id, account_id, folder, &final_progress);
|
||||
update_progress(&import_id, final_progress);
|
||||
}
|
||||
|
||||
/// Record a fatal failure and save history.
|
||||
fn fail_progress(
|
||||
import_id: &str,
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use compressed_rtf::*;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
|
||||
pub fn decode_subject(value: &PropertyValue) -> Option<String> {
|
||||
@@ -60,5 +58,5 @@ pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn decode_rtf_compressed(buffer: &[u8]) -> Option<String> {
|
||||
decompress_rtf(buffer).ok()
|
||||
compressed_rtf::decompress_rtf(buffer).ok()
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
//
|
||||
// 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::base64_encode_url_safe;
|
||||
use crate::envelope::extractor::extract_envelope_from_eml;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use mail_send::mail_builder::headers::text::Text;
|
||||
use mail_send::mail_builder::MessageBuilder;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||
use outlook_pst::messaging::folder::Folder;
|
||||
use outlook_pst::messaging::message::{Message, MessageProperties};
|
||||
use outlook_pst::ndb::node_id::NodeId;
|
||||
use std::rc::Rc;
|
||||
|
||||
mod encoding;
|
||||
|
||||
/// Convert a PST Message into a base64-encoded EML string.
|
||||
pub fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
|
||||
let properties = message.properties();
|
||||
|
||||
let mut builder = MessageBuilder::new();
|
||||
if let Some(sub) = extract_subject(properties) {
|
||||
builder = builder.subject(sub);
|
||||
}
|
||||
if let Some(mid) = extract_string_property(properties, 0x1035) {
|
||||
builder = builder.message_id(mid);
|
||||
}
|
||||
if let Some(irt) = extract_string_property(properties, 0x1042) {
|
||||
builder = builder.in_reply_to(irt);
|
||||
}
|
||||
|
||||
if let Some(refs) = extract_string_property(properties, 0x1039) {
|
||||
builder = builder.header("References", Text::new(refs));
|
||||
}
|
||||
|
||||
if let Some(cid_val) = properties.get(0x3013) {
|
||||
if let PropertyValue::Binary(bin) = cid_val {
|
||||
builder = builder.header(
|
||||
"X-Bichon-Conversation-ID",
|
||||
Text::new(hex::encode(bin.buffer())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let from = extract_string_property(properties, 0x5D01)
|
||||
.or_else(|| extract_string_property(properties, 0x5D02))
|
||||
.or_else(|| extract_string_property(properties, 0x0C1F));
|
||||
|
||||
if let Some(f) = from {
|
||||
builder = builder.from(f);
|
||||
}
|
||||
|
||||
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
|
||||
let dt = filetime_to_datetime(filetime).timestamp();
|
||||
builder = builder.date(dt);
|
||||
}
|
||||
|
||||
let (to, cc, bcc) = extract_recipients_list(&message);
|
||||
if !to.is_empty() {
|
||||
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !cc.is_empty() {
|
||||
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
if !bcc.is_empty() {
|
||||
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
if let Some(html) = extract_html(properties) {
|
||||
builder = builder.html_body(html);
|
||||
}
|
||||
|
||||
if let Some(text) = extract_text(properties) {
|
||||
builder = builder.text_body(text);
|
||||
}
|
||||
|
||||
if let Some(attachment_table) = message.attachment_table() {
|
||||
for row in attachment_table.rows_matrix() {
|
||||
let node_id = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
|
||||
let att_props = attachment.properties();
|
||||
let name = extract_attachment_string_property(att_props, 0x3707);
|
||||
let mime = extract_attachment_string_property(att_props, 0x370E)
|
||||
.unwrap_or_else(|| "application/octet-stream".into());
|
||||
let cid = extract_attachment_string_property(att_props, 0x3712);
|
||||
let is_inline = att_props
|
||||
.get(0x3714)
|
||||
.and_then(|val| {
|
||||
if let PropertyValue::Integer32(f) = val {
|
||||
Some(f)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|flag| (flag & 0x4) != 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
|
||||
let data = bin.buffer().to_vec();
|
||||
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
|
||||
|
||||
if is_inline && cid.is_some() {
|
||||
let content_id = cid.unwrap();
|
||||
builder = builder.inline(mime, content_id, data);
|
||||
} else {
|
||||
builder = builder.attachment(mime, file_name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match builder.write_to_vec() {
|
||||
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to generate EML from PST message: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
|
||||
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
|
||||
let nsecs = (filetime % 10_000_000) * 100;
|
||||
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
|
||||
}
|
||||
|
||||
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let mut to = Vec::new();
|
||||
let mut cc = Vec::new();
|
||||
let mut bcc = Vec::new();
|
||||
|
||||
let recipient_table = message.recipient_table();
|
||||
if let Some(recipient_table) = recipient_table {
|
||||
let context = recipient_table.context();
|
||||
for row in recipient_table.rows_matrix() {
|
||||
if let Ok(cols) = row.columns(context) {
|
||||
let mut r_type = 0;
|
||||
let mut email = String::new();
|
||||
|
||||
for (col, val) in context.columns().iter().zip(cols) {
|
||||
let prop_val = val
|
||||
.as_ref()
|
||||
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
|
||||
match col.prop_id() {
|
||||
0x0C15 => {
|
||||
if let Some(PropertyValue::Integer32(t)) = prop_val {
|
||||
r_type = t;
|
||||
}
|
||||
}
|
||||
0x39FE | 0x3003 => {
|
||||
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
|
||||
email = s;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !email.is_empty() {
|
||||
match r_type {
|
||||
1 => to.push(email),
|
||||
2 => cc.push(email),
|
||||
3 => bcc.push(email),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let receiver = extract_string_property(message.properties(), 0x0076);
|
||||
if let Some(receiver) = receiver {
|
||||
to.push(receiver);
|
||||
}
|
||||
}
|
||||
(to, cc, bcc)
|
||||
}
|
||||
|
||||
fn extract_subject(props: &MessageProperties) -> Option<String> {
|
||||
props.get(0x0037).and_then(|val| encoding::decode_subject(val))
|
||||
}
|
||||
|
||||
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_attachment_string_property(
|
||||
properties: &AttachmentProperties,
|
||||
prop_id: u16,
|
||||
) -> Option<String> {
|
||||
properties
|
||||
.get(prop_id)
|
||||
.and_then(|value| extract_string(value))
|
||||
}
|
||||
|
||||
fn extract_string(value: &PropertyValue) -> Option<String> {
|
||||
match value {
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_text(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1000).and_then(extract_string).or_else(|| {
|
||||
properties.get(0x1009).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_html(properties: &MessageProperties) -> Option<String> {
|
||||
properties.get(0x1013).and_then(|value| match value {
|
||||
PropertyValue::Binary(value) => {
|
||||
let code_page = properties
|
||||
.get(0x3FDE)
|
||||
.and_then(|v| {
|
||||
if let PropertyValue::Integer32(cpid) = v {
|
||||
Some(*cpid as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(65001);
|
||||
encoding::decode_html_body(value.buffer(), code_page)
|
||||
}
|
||||
PropertyValue::String8(value) => Some(value.to_string()),
|
||||
PropertyValue::Unicode(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
|
||||
for &prop_id in prop_ids {
|
||||
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
|
||||
return Some(*value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Open a PST file and count total messages across all folders.
|
||||
/// Called from the web upload flow to get the total before processing.
|
||||
pub fn count_pst_messages(pst_path: &std::path::Path) -> crate::error::BichonResult<usize> {
|
||||
let pst_store = outlook_pst::open_store(pst_path).map_err(|e| {
|
||||
crate::raise_error!(
|
||||
format!("Failed to open PST file: {:?}", e),
|
||||
crate::error::code::ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
|
||||
let ipm_sub_tree = pst_store.properties().ipm_sub_tree_entry_id().map_err(|e| {
|
||||
crate::raise_error!(
|
||||
format!("Could not find IPM_SUBTREE in PST: {:?}", e),
|
||||
crate::error::code::ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
|
||||
let ipm_subtree_folder = pst_store.open_folder(&ipm_sub_tree).map_err(|e| {
|
||||
crate::raise_error!(
|
||||
format!("Failed to open root mailbox folder: {:?}", e),
|
||||
crate::error::code::ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(count_folder_messages(&ipm_subtree_folder))
|
||||
}
|
||||
|
||||
fn count_folder_messages(folder: &Rc<dyn Folder>) -> usize {
|
||||
let mut count = 0usize;
|
||||
|
||||
if let Some(contents_table) = folder.contents_table() {
|
||||
for row in contents_table.rows_matrix() {
|
||||
let store = folder.store().clone();
|
||||
let entry_id = match store
|
||||
.properties()
|
||||
.make_entry_id(NodeId::from(u32::from(row.id())))
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if store.open_message(&entry_id, None).is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hierarchy_table) = folder.hierarchy_table() {
|
||||
for row in hierarchy_table.rows_matrix() {
|
||||
let node = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
|
||||
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
|
||||
count += count_folder_messages(&sub_folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
/// Walk all folders and process messages, calling the progress callback
|
||||
/// every 50 messages. Used by the web upload flow.
|
||||
pub fn process_folder_with_progress<F>(
|
||||
folder: &Rc<dyn Folder>,
|
||||
parent_path: &str,
|
||||
account_id: u64,
|
||||
total: usize,
|
||||
success_count: &mut usize,
|
||||
failed_details: &mut Vec<super::FailedItemDetail>,
|
||||
index: &mut usize,
|
||||
progress_cb: &F,
|
||||
) where
|
||||
F: Fn(usize, usize), // (processed, failed)
|
||||
{
|
||||
process_folder_with_progress_inner(
|
||||
folder,
|
||||
parent_path,
|
||||
account_id,
|
||||
total,
|
||||
success_count,
|
||||
failed_details,
|
||||
index,
|
||||
progress_cb,
|
||||
);
|
||||
}
|
||||
|
||||
fn process_folder_with_progress_inner<F>(
|
||||
folder: &Rc<dyn Folder>,
|
||||
parent_path: &str,
|
||||
account_id: u64,
|
||||
total: usize,
|
||||
success_count: &mut usize,
|
||||
failed_details: &mut Vec<super::FailedItemDetail>,
|
||||
index: &mut usize,
|
||||
progress_cb: &F,
|
||||
) where
|
||||
F: Fn(usize, usize),
|
||||
{
|
||||
let folder_name = folder
|
||||
.properties()
|
||||
.display_name()
|
||||
.unwrap_or_else(|_| "Unknown".to_string());
|
||||
|
||||
let mail_folder = if parent_path.is_empty() {
|
||||
folder_name
|
||||
} else {
|
||||
format!("{}/{}", parent_path, folder_name)
|
||||
};
|
||||
|
||||
tracing::debug!("Processing PST folder: {}", mail_folder);
|
||||
|
||||
let mailbox_id = match super::resolve_mailbox_by_account_id(account_id, &mail_folder) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to resolve mailbox '{}': {:?}", mail_folder, e);
|
||||
// Still recurse into sub-folders even if this folder's mailbox creation fails
|
||||
if let Some(hierarchy_table) = folder.hierarchy_table() {
|
||||
for row in hierarchy_table.rows_matrix() {
|
||||
let node = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
|
||||
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
|
||||
process_folder_with_progress_inner(
|
||||
&sub_folder,
|
||||
&mail_folder,
|
||||
account_id,
|
||||
total,
|
||||
success_count,
|
||||
failed_details,
|
||||
index,
|
||||
progress_cb,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut batch_size = 0usize;
|
||||
|
||||
if let Some(contents_table) = folder.contents_table() {
|
||||
for row in contents_table.rows_matrix() {
|
||||
let store = folder.store().clone();
|
||||
|
||||
let entry_id = match store
|
||||
.properties()
|
||||
.make_entry_id(NodeId::from(u32::from(row.id())))
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::warn!("Skip PST row {}: {:?}", row.unique(), e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match store.open_message(&entry_id, None) {
|
||||
Ok(message) => match build_eml_base64(message) {
|
||||
Some(base64_eml) => {
|
||||
let decoded = match crate::base64_decode_url_safe!(base64_eml.as_bytes()) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
failed_details.push(super::FailedItemDetail {
|
||||
index: *index,
|
||||
error_message: format!(
|
||||
"Failed to decode base64 EML at index {}: {:?}",
|
||||
*index, e
|
||||
),
|
||||
});
|
||||
*index += 1;
|
||||
batch_size += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match futures::executor::block_on(
|
||||
extract_envelope_from_eml(&decoded, account_id, mailbox_id)
|
||||
) {
|
||||
Ok(_) => {
|
||||
*success_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
failed_details.push(super::FailedItemDetail {
|
||||
index: *index,
|
||||
error_message: format!("{:?}", e),
|
||||
});
|
||||
}
|
||||
};
|
||||
*index += 1;
|
||||
batch_size += 1;
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Open PST message error: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Report progress every 50 messages
|
||||
if batch_size % 50 == 0 {
|
||||
progress_cb(*success_count + failed_details.len(), failed_details.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hierarchy_table) = folder.hierarchy_table() {
|
||||
for row in hierarchy_table.rows_matrix() {
|
||||
let node = NodeId::from(u32::from(row.id()));
|
||||
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
|
||||
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
|
||||
process_folder_with_progress_inner(
|
||||
&sub_folder,
|
||||
&mail_folder,
|
||||
account_id,
|
||||
total,
|
||||
success_count,
|
||||
failed_details,
|
||||
index,
|
||||
progress_cb,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,6 +339,25 @@ pub struct Settings {
|
||||
help = "Maximum HTTP request body size in MB for file uploads"
|
||||
)]
|
||||
pub bichon_upload_body_limit_mb: u64,
|
||||
|
||||
/// Maximum per-file size in MB for MBOX uploads via the web UI (default: 1024 MB = 1 GB).
|
||||
/// Individual EML files are always capped at 100 MB regardless of this setting.
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "1024",
|
||||
env,
|
||||
help = "Maximum per-file size in MB for MBOX uploads via the web UI"
|
||||
)]
|
||||
pub bichon_web_mbox_upload_limit_mb: u64,
|
||||
|
||||
/// Maximum per-file size in MB for PST uploads via the web UI (default: 2048 MB = 2 GB).
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "2048",
|
||||
env,
|
||||
help = "Maximum per-file size in MB for PST uploads via the web UI"
|
||||
)]
|
||||
pub bichon_web_pst_upload_limit_mb: u64,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
|
||||
@@ -67,6 +67,10 @@ pub struct SystemConfigurations {
|
||||
pub bichon_oidc_redirect_uri: Option<String>,
|
||||
|
||||
pub bichon_upload_body_limit_mb: u64,
|
||||
|
||||
pub bichon_web_mbox_upload_limit_mb: u64,
|
||||
|
||||
pub bichon_web_pst_upload_limit_mb: u64,
|
||||
}
|
||||
|
||||
impl From<&Settings> for SystemConfigurations {
|
||||
@@ -106,6 +110,8 @@ impl From<&Settings> for SystemConfigurations {
|
||||
bichon_oidc_client_id: s.bichon_oidc_client_id.clone(),
|
||||
bichon_oidc_redirect_uri: s.bichon_oidc_redirect_uri.clone(),
|
||||
bichon_upload_body_limit_mb: s.bichon_upload_body_limit_mb,
|
||||
bichon_web_mbox_upload_limit_mb: s.bichon_web_mbox_upload_limit_mb,
|
||||
bichon_web_pst_upload_limit_mb: s.bichon_web_pst_upload_limit_mb,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ 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, MAX_WEB_MBOX_BYTES,
|
||||
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;
|
||||
@@ -145,10 +146,11 @@ impl ImportApi {
|
||||
.unwrap_or_default();
|
||||
let is_mbox_ext = ext_lower == "mbox";
|
||||
let is_eml_ext = ext_lower == "eml";
|
||||
if !is_mbox_ext && !is_eml_ext {
|
||||
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 and .mbox files are allowed.",
|
||||
"Unsupported file type '.{}'. Only .eml, .mbox and .pst files are allowed.",
|
||||
ext_lower
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
@@ -156,7 +158,15 @@ impl ImportApi {
|
||||
}
|
||||
|
||||
// Check disk space (fail fast before streaming)
|
||||
let min_required = if is_mbox_ext { MAX_WEB_MBOX_BYTES } else { MAX_WEB_EML_BYTES };
|
||||
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;
|
||||
@@ -184,19 +194,30 @@ impl ImportApi {
|
||||
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 { FileFormat::Eml }
|
||||
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_WEB_MBOX_BYTES,
|
||||
FileFormat::Mbox => max_mbox,
|
||||
FileFormat::Pst => max_pst,
|
||||
FileFormat::Eml => MAX_WEB_EML_BYTES,
|
||||
};
|
||||
if file_len > max_size {
|
||||
@@ -295,13 +316,24 @@ impl ImportApi {
|
||||
/// 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_stream = if is_mbox_ext { MAX_WEB_MBOX_BYTES } else { MAX_WEB_EML_BYTES };
|
||||
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!(
|
||||
@@ -353,12 +385,12 @@ async fn stream_body_to_temp(
|
||||
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.
|
||||
// But if content doesn't look like either, reject.
|
||||
if !detect_text_file(&first_chunk) {
|
||||
// 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 and .mbox text files are accepted.".into(),
|
||||
"The uploaded file appears to be binary (not a valid email file). Only .eml, .mbox and .pst files are accepted.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
))?;
|
||||
}
|
||||
|
||||
@@ -138,6 +138,10 @@ export type ServerConfigurations = {
|
||||
bichon_smtp_auth_required: boolean
|
||||
bichon_smtp_tls_key_path?: string | null
|
||||
bichon_smtp_tls_cert_path?: string | null
|
||||
|
||||
bichon_upload_body_limit_mb: number
|
||||
bichon_web_mbox_upload_limit_mb: number
|
||||
bichon_web_pst_upload_limit_mb: number
|
||||
}
|
||||
|
||||
export const get_dashboard_stats = async () => {
|
||||
|
||||
@@ -104,7 +104,7 @@ export interface FolderHint {
|
||||
/** The suggested folder name. */
|
||||
name: string;
|
||||
/** Where the hint came from. */
|
||||
source: 'gmail-labels' | 'bichon-metadata' | 'filename' | 'mbox-filename';
|
||||
source: 'gmail-labels' | 'bichon-metadata' | 'filename' | 'mbox-filename' | 'pst-filename';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,5 +142,11 @@ export async function extractFolderHint(file: File): Promise<FolderHint | null>
|
||||
const fnFolder = folderFromFileName(file.name);
|
||||
if (fnFolder) return { name: fnFolder, source: 'filename' };
|
||||
|
||||
// 5. For PST files, try the filename
|
||||
if (isPst) {
|
||||
const fnFolder = folderFromFileName(file.name);
|
||||
if (fnFolder) return { name: fnFolder, source: 'pst-filename' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -42,11 +42,13 @@ import {
|
||||
type ImportProgress,
|
||||
type ImportHistory,
|
||||
} from '@/api/import/api';
|
||||
import { get_system_configurations } from '@/api/system/api';
|
||||
import { list_mailboxes } from '@/api/mailbox/api';
|
||||
import { extractFolderHint, type FolderHint } from './folder-hint';
|
||||
|
||||
const MAX_EML = 100 * 1024 * 1024; // 100 MB
|
||||
const MAX_MBOX = 1024 * 1024 * 1024; // 1 GB
|
||||
const MAX_EML = 100 * 1024 * 1024; // 100 MB (hardcoded)
|
||||
const DEFAULT_MAX_MBOX = 1024 * 1024 * 1024; // 1 GB (fallback; actual limit from server settings)
|
||||
const DEFAULT_MAX_PST = 2048 * 1024 * 1024; // 2 GB (fallback; actual limit from server settings)
|
||||
|
||||
// MIME types that are clearly NOT email files — reject these upfront.
|
||||
const BLOCKED_MIME_PREFIXES = [
|
||||
@@ -66,10 +68,10 @@ function isValidFileType(file: File, ext: string): boolean {
|
||||
}
|
||||
}
|
||||
// Check extension
|
||||
return ext === 'eml' || ext === 'mbox';
|
||||
return ext === 'eml' || ext === 'mbox' || ext === 'pst';
|
||||
}
|
||||
|
||||
type FolderMode = 'header' | 'existing' | 'custom';
|
||||
type FolderMode = '' | 'header' | 'existing' | 'custom';
|
||||
|
||||
interface QueuedFile {
|
||||
file: File;
|
||||
@@ -89,6 +91,7 @@ function folderHintLabel(hint: FolderHint): string {
|
||||
case 'bichon-metadata': return 'X-Bichon-Metadata';
|
||||
case 'filename': return 'filename';
|
||||
case 'mbox-filename': return 'mbox filename';
|
||||
case 'pst-filename': return 'PST filename';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +100,7 @@ export default function ImportPage() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [accountId, setAccountId] = useState<string>('');
|
||||
const [folderMode, setFolderMode] = useState<FolderMode>('header');
|
||||
const [folderMode, setFolderMode] = useState<FolderMode>('');
|
||||
const [folder, setFolder] = useState('INBOX');
|
||||
const [files, setFiles] = useState<QueuedFile[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -107,6 +110,7 @@ export default function ImportPage() {
|
||||
const [phase, setPhase] = useState<'idle' | 'uploading' | 'processing' | 'done'>('idle');
|
||||
const [folderHint, setFolderHint] = useState<FolderHint | null>(null);
|
||||
const [headerFolder, setHeaderFolder] = useState('INBOX');
|
||||
const [isPstSelected, setIsPstSelected] = useState(false);
|
||||
|
||||
// Combobox state for existing mailbox selection
|
||||
const [mailboxOpen, setMailboxOpen] = useState(false);
|
||||
@@ -129,6 +133,21 @@ export default function ImportPage() {
|
||||
});
|
||||
const mailboxes = mailboxData?.mailboxes ?? [];
|
||||
|
||||
// Fetch system config to get the configured MBOX/PST upload limits.
|
||||
// Falls back to defaults for non-root users or on error.
|
||||
const { data: sysConfig } = useQuery({
|
||||
queryKey: ['system-configurations'],
|
||||
queryFn: get_system_configurations,
|
||||
staleTime: 300_000,
|
||||
retry: false,
|
||||
});
|
||||
const maxMbox = sysConfig
|
||||
? sysConfig.bichon_web_mbox_upload_limit_mb * 1024 * 1024
|
||||
: DEFAULT_MAX_MBOX;
|
||||
const maxPst = sysConfig
|
||||
? sysConfig.bichon_web_pst_upload_limit_mb * 1024 * 1024
|
||||
: DEFAULT_MAX_PST;
|
||||
|
||||
// Import history
|
||||
const { data: history = [], refetch: refetchHistory } = useQuery({
|
||||
queryKey: ['import-history'],
|
||||
@@ -144,6 +163,8 @@ export default function ImportPage() {
|
||||
case 'existing':
|
||||
case 'custom':
|
||||
return folder;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -179,7 +200,8 @@ export default function ImportPage() {
|
||||
const queued: QueuedFile[] = arr.map((f) => {
|
||||
const ext = f.name.split('.').pop()?.toLowerCase() || '';
|
||||
const isMbox = ext === 'mbox';
|
||||
const max = isMbox ? MAX_MBOX : MAX_EML;
|
||||
const isPst = ext === 'pst';
|
||||
const max = isMbox ? maxMbox : isPst ? maxPst : MAX_EML;
|
||||
const typeOk = isValidFileType(f, ext);
|
||||
return { file: f, sizeOk: f.size <= max, typeOk };
|
||||
});
|
||||
@@ -189,17 +211,32 @@ export default function ImportPage() {
|
||||
setProgress(null);
|
||||
//setImportId(null);
|
||||
|
||||
// Extract folder hint from the first valid file
|
||||
// Extract folder hint from the first valid file.
|
||||
// PST files are binary (OLE2) — headers can't be extracted in-browser.
|
||||
const firstOk = queued.find((q) => q.sizeOk && q.typeOk);
|
||||
if (firstOk) {
|
||||
try {
|
||||
const hint = await extractFolderHint(firstOk.file);
|
||||
if (hint) {
|
||||
setFolderHint(hint);
|
||||
setHeaderFolder(hint.name);
|
||||
const ext = firstOk.file.name.split('.').pop()?.toLowerCase() || '';
|
||||
const isPstFile = ext === 'pst';
|
||||
setIsPstSelected(isPstFile);
|
||||
if (isPstFile) {
|
||||
// PST: folder structure is auto-detected, no manual mode needed
|
||||
setFolderHint(null);
|
||||
setHeaderFolder('INBOX');
|
||||
setFolderMode('');
|
||||
} else {
|
||||
// EML/MBOX: default to header auto-detect if no mode selected yet
|
||||
if (!folderMode) {
|
||||
setFolderMode('header');
|
||||
}
|
||||
try {
|
||||
const hint = await extractFolderHint(firstOk.file);
|
||||
if (hint) {
|
||||
setFolderHint(hint);
|
||||
setHeaderFolder(hint.name);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
@@ -209,6 +246,8 @@ export default function ImportPage() {
|
||||
if (files.length <= 1) {
|
||||
setFolderHint(null);
|
||||
setHeaderFolder('INBOX');
|
||||
setFolderMode('');
|
||||
setIsPstSelected(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,7 +312,7 @@ export default function ImportPage() {
|
||||
<div className="flex-1 space-y-6 p-6 md:p-8 max-w-3xl mx-auto">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
{t('import.title', 'Import EML / MBOX')}
|
||||
{t('import.title', 'Import EML / MBOX / PST')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('import.description', 'Import email files into a NoSync account. For larger files, use the CLI.')}
|
||||
@@ -350,13 +389,20 @@ export default function ImportPage() {
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t('import.folderMethod', '2. Choose folder method')}
|
||||
{isPstSelected
|
||||
? t('import.folderStructure', '2. Folder structure')
|
||||
: t('import.folderMethod', '2. Choose folder method')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{t('import.folderMethodDesc', 'How should the target mail folder be determined?')}
|
||||
{isPstSelected
|
||||
? t('import.pstFolderDesc', 'The PST file contains its own folder structure (e.g. Inbox, Sent Items, etc.). Folders will be automatically created during import.')
|
||||
: files.length === 0
|
||||
? t('import.selectFileFirst', 'Select a file first to determine available options.')
|
||||
: t('import.folderMethodDesc', 'How should the target mail folder be determined?')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isPstSelected && (
|
||||
<RadioGroup
|
||||
value={folderMode}
|
||||
onValueChange={(v) => handleModeChange(v as FolderMode)}
|
||||
@@ -516,6 +562,7 @@ export default function ImportPage() {
|
||||
</div>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -526,7 +573,11 @@ export default function ImportPage() {
|
||||
{t('import.chooseFiles', '3. Choose files')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{t('import.limits', 'Max: EML 100 MB · MBOX 1 GB. Larger files → CLI.')}
|
||||
{t('import.limits', {
|
||||
defaultValue: 'Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Larger files → CLI.',
|
||||
maxMbox: (maxMbox / (1024 * 1024)).toFixed(0),
|
||||
maxPst: (maxPst / (1024 * 1024)).toFixed(0)
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -542,7 +593,7 @@ export default function ImportPage() {
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.eml,.mbox,message/rfc822,application/mbox,text/plain';
|
||||
input.accept = '.eml,.mbox,.pst,message/rfc822,application/mbox,text/plain';
|
||||
input.multiple = true;
|
||||
input.onchange = () => input.files && handleFiles(input.files);
|
||||
input.click();
|
||||
@@ -550,7 +601,7 @@ export default function ImportPage() {
|
||||
>
|
||||
<Upload className="mx-auto h-10 w-10 text-muted-foreground/60 mb-3" />
|
||||
<p className="text-sm font-medium">
|
||||
{t('import.dropHere', 'Drop .eml / .mbox files here')}
|
||||
{t('import.dropHere', 'Drop .eml / .mbox / .pst files here')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('import.orClick', 'or click to browse')}
|
||||
@@ -677,7 +728,9 @@ export default function ImportPage() {
|
||||
{/* Import button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('import.willImportTo', 'Will import to')}: <span className="font-medium text-foreground">{effectiveFolder}</span>
|
||||
{isPstSelected
|
||||
? t('import.pstFolders', 'PST folder structure will be preserved during import')
|
||||
: (<>{t('import.willImportTo', 'Will import to')}: <span className="font-medium text-foreground">{effectiveFolder}</span></>)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => importMutation.mutate()}
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "استيراد ملفات البريد إلى حساب محلي (NoSync). للملفات الكبيرة، استخدم CLI.",
|
||||
"detectedFolder": "مكتشف",
|
||||
"detectedFrom": "مكتشف من",
|
||||
"dropHere": "أفلت ملفات .eml / .mbox هنا",
|
||||
"dropHere": "أفلت ملفات .eml / .mbox / .pst هنا",
|
||||
"failed": "فشل الاستيراد",
|
||||
"failedCount": "{{count}} فشل",
|
||||
"failedDetails": "العناصر الفاشلة",
|
||||
"folder": "المجلد",
|
||||
"folderMethod": "2. اختر طريقة تحديد المجلد",
|
||||
"folderMethodDesc": "كيف سيتم تحديد مجلد البريد المستهدف؟",
|
||||
"folderStructure": "2. هيكل المجلدات",
|
||||
"importHistory": "سجل الاستيراد",
|
||||
"limits": "الحد الأقصى: EML 100 م.ب · MBOX 1 غ.ب. للملفات الأكبر ← CLI.",
|
||||
"limits": "الحد الأقصى: EML 100 م.ب · MBOX {{maxMbox}} م.ب · PST {{maxPst}} م.ب. للملفات الأكبر ← CLI.",
|
||||
"modeCustom": "أدخل اسم مجلد مخصص",
|
||||
"modeCustomDesc": "اكتب اسم مجلد البريد المستهدف يدويًا.",
|
||||
"modeExisting": "اختر من صناديق البريد الحالية",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "أو انقر للتصفح",
|
||||
"processed": "تم معالجة {{current}} / {{total}}",
|
||||
"processing": "جاري المعالجة…",
|
||||
"pstFolderDesc": "يحتوي ملف PST على هيكل مجلدات خاص به (مثل Inbox و Sent Items وما إلى ذلك). سيتم إنشاء المجلدات تلقائيًا أثناء الاستيراد.",
|
||||
"pstFolders": "سيتم الحفاظ على هيكل مجلدات PST أثناء الاستيراد",
|
||||
"searchAccount": "البحث عن الحسابات...",
|
||||
"searchMailbox": "البحث عن صناديق البريد...",
|
||||
"selectAccount": "اختر حسابًا",
|
||||
"selectAccountFirst": "يرجى اختيار حساب أولاً.",
|
||||
"selectFileFirst": "يرجى اختيار ملف أولاً لتحديد الخيارات المتاحة.",
|
||||
"selectMailbox": "اختر صندوق بريد...",
|
||||
"source": "المصدر",
|
||||
"startImport": "استيراد",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Importer e-mailfiler til en lokal konto (NoSync). Brug CLI til større filer.",
|
||||
"detectedFolder": "Registreret",
|
||||
"detectedFrom": "Registreret fra",
|
||||
"dropHere": "Slip .eml / .mbox-filer her",
|
||||
"dropHere": "Slip .eml / .mbox / .pst-filer her",
|
||||
"failed": "Import mislykkedes",
|
||||
"failedCount": "{{count}} fejlet",
|
||||
"failedDetails": "Fejlede elementer",
|
||||
"folder": "Mappe",
|
||||
"folderMethod": "2. Vælg mappemetode",
|
||||
"folderMethodDesc": "Hvordan skal destinationsmappen bestemmes?",
|
||||
"folderStructure": "2. Mappestruktur",
|
||||
"importHistory": "Importhistorik",
|
||||
"limits": "Maks: EML 100 MB · MBOX 1 GB. Større filer → CLI.",
|
||||
"limits": "Maks: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Større filer → CLI.",
|
||||
"modeCustom": "Indtast et brugerdefineret mappenavn",
|
||||
"modeCustomDesc": "Skriv navnet på destinationsmappen manuelt.",
|
||||
"modeExisting": "Vælg fra eksisterende postkasser",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "eller klik for at gennemse",
|
||||
"processed": "{{current}} / {{total}} behandlet",
|
||||
"processing": "Behandler…",
|
||||
"pstFolderDesc": "PST-filen indeholder sin egen mappestruktur (f.eks. Indbakke, Sendt post osv.). Mapper oprettes automatisk under importen.",
|
||||
"pstFolders": "PST-mappestrukturen vil blive bevaret under importen",
|
||||
"searchAccount": "Søg efter konti...",
|
||||
"searchMailbox": "Søg efter postkasser...",
|
||||
"selectAccount": "Vælg en konto",
|
||||
"selectAccountFirst": "Vælg en konto først.",
|
||||
"selectFileFirst": "Vælg en fil først for at bestemme tilgængelige muligheder.",
|
||||
"selectMailbox": "Vælg en postkasse...",
|
||||
"source": "kilde",
|
||||
"startImport": "Importer",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "E-Mail-Dateien in ein lokales Konto (NoSync) importieren. Für größere Dateien CLI nutzen.",
|
||||
"detectedFolder": "Erkannt",
|
||||
"detectedFrom": "Erkannt aus",
|
||||
"dropHere": ".eml / .mbox-Dateien hierher ziehen",
|
||||
"dropHere": ".eml / .mbox / .pst-Dateien hierher ziehen",
|
||||
"failed": "Import fehlgeschlagen",
|
||||
"failedCount": "{{count}} fehlgeschlagen",
|
||||
"failedDetails": "Fehlgeschlagene Elemente",
|
||||
"folder": "Ordner",
|
||||
"folderMethod": "2. Ordnermethode wählen",
|
||||
"folderMethodDesc": "Wie soll der Zielordner bestimmt werden?",
|
||||
"folderStructure": "2. Ordnerstruktur",
|
||||
"importHistory": "Importverlauf",
|
||||
"limits": "Max: EML 100 MB · MBOX 1 GB. Größere Dateien → CLI.",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Größere Dateien → CLI.",
|
||||
"modeCustom": "Benutzerdefinierten Ordnernamen eingeben",
|
||||
"modeCustomDesc": "Geben Sie den Namen des Zielordners manuell ein.",
|
||||
"modeExisting": "Aus bestehenden Postfächern wählen",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "oder zum Durchsuchen klicken",
|
||||
"processed": "{{current}} / {{total}} verarbeitet",
|
||||
"processing": "Verarbeitung…",
|
||||
"pstFolderDesc": "Die PST-Datei enthält eine eigene Ordnerstruktur (z. B. Posteingang, Gesendete Elemente usw.). Ordner werden beim Import automatisch erstellt.",
|
||||
"pstFolders": "Die PST-Ordnerstruktur wird beim Import beibehalten",
|
||||
"searchAccount": "Konten suchen...",
|
||||
"searchMailbox": "Postfächer suchen...",
|
||||
"selectAccount": "Konto auswählen",
|
||||
"selectAccountFirst": "Wählen Sie zuerst ein Konto aus.",
|
||||
"selectFileFirst": "Wählen Sie zuerst eine Datei aus, um die verfügbaren Optionen zu ermitteln.",
|
||||
"selectMailbox": "Postfach auswählen...",
|
||||
"source": "Quelle",
|
||||
"startImport": "Importieren",
|
||||
|
||||
@@ -598,15 +598,16 @@
|
||||
"description": "Import email files into a local account (NoSync). For larger files, use the CLI.",
|
||||
"detectedFolder": "Detected",
|
||||
"detectedFrom": "Detected from",
|
||||
"dropHere": "Drop .eml / .mbox files here",
|
||||
"dropHere": "Drop .eml / .mbox / .pst files here",
|
||||
"failed": "Import failed",
|
||||
"failedCount": "{{count}} failed",
|
||||
"failedDetails": "Failed items",
|
||||
"folder": "Folder",
|
||||
"folderMethod": "2. Choose folder method",
|
||||
"folderMethodDesc": "How should the target mail folder be determined?",
|
||||
"folderStructure": "2. Folder structure",
|
||||
"importHistory": "Import History",
|
||||
"limits": "Max: EML 100 MB · MBOX 1 GB. Larger files → CLI.",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Larger files → CLI.",
|
||||
"modeCustom": "Enter a custom folder name",
|
||||
"modeCustomDesc": "Manually type the target mail folder name.",
|
||||
"modeExisting": "Choose from existing mailboxes",
|
||||
@@ -620,10 +621,13 @@
|
||||
"orClick": "or click to browse",
|
||||
"processed": "{{current}} / {{total}} processed",
|
||||
"processing": "Processing…",
|
||||
"pstFolderDesc": "The PST file contains its own folder structure (e.g. Inbox, Sent Items, etc.). Folders will be automatically created during import.",
|
||||
"pstFolders": "PST folder structure will be preserved during import",
|
||||
"searchAccount": "Search accounts...",
|
||||
"searchMailbox": "Search mailboxes...",
|
||||
"selectAccount": "Select an account",
|
||||
"selectAccountFirst": "Select an account first.",
|
||||
"selectFileFirst": "Select a file first to determine available options.",
|
||||
"selectMailbox": "Select a mailbox...",
|
||||
"source": "source",
|
||||
"startImport": "Import",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Importar archivos de correo a una cuenta local (NoSync). Para archivos más grandes, use la CLI.",
|
||||
"detectedFolder": "Detectado",
|
||||
"detectedFrom": "Detectado de",
|
||||
"dropHere": "Arrastre archivos .eml / .mbox aquí",
|
||||
"dropHere": "Arrastre archivos .eml / .mbox / .pst aquí",
|
||||
"failed": "Error al importar",
|
||||
"failedCount": "{{count}} fallidos",
|
||||
"failedDetails": "Elementos fallidos",
|
||||
"folder": "Carpeta",
|
||||
"folderMethod": "2. Elegir método de carpeta",
|
||||
"folderMethodDesc": "¿Cómo se debe determinar la carpeta de correo de destino?",
|
||||
"folderStructure": "2. Estructura de carpetas",
|
||||
"importHistory": "Historial de importación",
|
||||
"limits": "Máx: EML 100 MB · MBOX 1 GB. Archivos más grandes → CLI.",
|
||||
"limits": "Máx: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Archivos más grandes → CLI.",
|
||||
"modeCustom": "Ingresar un nombre de carpeta personalizado",
|
||||
"modeCustomDesc": "Escriba manualmente el nombre de la carpeta de destino.",
|
||||
"modeExisting": "Elegir de buzones existentes",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "o haga clic para buscar",
|
||||
"processed": "{{current}} / {{total}} procesados",
|
||||
"processing": "Procesando…",
|
||||
"pstFolderDesc": "El archivo PST contiene su propia estructura de carpetas (por ejemplo, Bandeja de entrada, Elementos enviados, etc.). Las carpetas se crearán automáticamente durante la importación.",
|
||||
"pstFolders": "Se conservará la estructura de carpetas PST durante la importación",
|
||||
"searchAccount": "Buscar cuentas...",
|
||||
"searchMailbox": "Buscar buzones...",
|
||||
"selectAccount": "Seleccionar una cuenta",
|
||||
"selectAccountFirst": "Seleccione una cuenta primero.",
|
||||
"selectFileFirst": "Seleccione un archivo primero para determinar las opciones disponibles.",
|
||||
"selectMailbox": "Seleccionar buzón...",
|
||||
"source": "origen",
|
||||
"startImport": "Importar",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Tuo sähköpostitiedostoja paikalliselle tilille (NoSync). Käytä CLI:tä suuremmille tiedostoille.",
|
||||
"detectedFolder": "Tunnistettu",
|
||||
"detectedFrom": "Tunnistettu lähteestä",
|
||||
"dropHere": "Pudota .eml / .mbox -tiedostot tähän",
|
||||
"dropHere": "Pudota .eml / .mbox / .pst -tiedostot tähän",
|
||||
"failed": "Tuonti epäonnistui",
|
||||
"failedCount": "{{count}} epäonnistui",
|
||||
"failedDetails": "Epäonnistuneet kohteet",
|
||||
"folder": "Kansio",
|
||||
"folderMethod": "2. Valitse kansiomenetelmä",
|
||||
"folderMethodDesc": "Miten kohdekansio tulisi määrittää?",
|
||||
"folderStructure": "2. Kansionrakenne",
|
||||
"importHistory": "Tuontihistoria",
|
||||
"limits": "Max: EML 100 MB · MBOX 1 GB. Suuremmat tiedostot → CLI.",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Suuremmat tiedostot → CLI.",
|
||||
"modeCustom": "Syötä mukautettu kansion nimi",
|
||||
"modeCustomDesc": "Kirjoita kohdekansion nimi manuaalisesti.",
|
||||
"modeExisting": "Valitse olemassa olevista postilaatikoista",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "tai napsauta selataksesi",
|
||||
"processed": "{{current}} / {{total}} käsitelty",
|
||||
"processing": "Käsitellään…",
|
||||
"pstFolderDesc": "PST-tiedosto sisältää oman kansionrakenneensa (esim. Saapuneet, Lähetetyt jne.). Kansiot luodaan automaattisesti tuonnin aikana.",
|
||||
"pstFolders": "PST-kansionrakenne säilytetään tuonnin aikana",
|
||||
"searchAccount": "Etsi tilejä...",
|
||||
"searchMailbox": "Etsi postilaatikoita...",
|
||||
"selectAccount": "Valitse tili",
|
||||
"selectAccountFirst": "Valitse ensin tili.",
|
||||
"selectFileFirst": "Valitse ensin tiedosto määrittääksesi käytettävissä olevat vaihtoehdot.",
|
||||
"selectMailbox": "Valitse postilaatikko...",
|
||||
"source": "lähde",
|
||||
"startImport": "Tuo",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Importer des fichiers d'e-mails dans un compte local (NoSync). Pour les gros fichiers, utilisez le CLI.",
|
||||
"detectedFolder": "Détecté",
|
||||
"detectedFrom": "Détecté depuis",
|
||||
"dropHere": "Déposez les fichiers .eml / .mbox ici",
|
||||
"dropHere": "Déposez les fichiers .eml / .mbox / .pst ici",
|
||||
"failed": "Échec de l'importation",
|
||||
"failedCount": "{{count}} échoué(s)",
|
||||
"failedDetails": "Éléments en échec",
|
||||
"folder": "Dossier",
|
||||
"folderMethod": "2. Choisir la méthode de dossier",
|
||||
"folderMethodDesc": "Comment le dossier de destination doit-il être déterminé ?",
|
||||
"folderStructure": "2. Structure des dossiers",
|
||||
"importHistory": "Historique d'importation",
|
||||
"limits": "Max : EML 100 MB · MBOX 1 GB. Fichiers plus volumineux → CLI.",
|
||||
"limits": "Max : EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Fichiers plus volumineux → CLI.",
|
||||
"modeCustom": "Saisir un nom de dossier personnalisé",
|
||||
"modeCustomDesc": "Saisissez manuellement le nom du dossier de destination.",
|
||||
"modeExisting": "Choisir parmi les boîtes existantes",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "ou cliquez pour parcourir",
|
||||
"processed": "{{current}} / {{total}} traités",
|
||||
"processing": "Traitement…",
|
||||
"pstFolderDesc": "Le fichier PST contient sa propre structure de dossiers (par ex. Boîte de réception, Éléments envoyés, etc.). Les dossiers seront créés automatiquement lors de l'importation.",
|
||||
"pstFolders": "La structure des dossiers PST sera préservée lors de l'importation",
|
||||
"searchAccount": "Rechercher des comptes...",
|
||||
"searchMailbox": "Rechercher des boîtes...",
|
||||
"selectAccount": "Sélectionner un compte",
|
||||
"selectAccountFirst": "Sélectionnez d'abord un compte.",
|
||||
"selectFileFirst": "Sélectionnez d'abord un fichier si vous souhaitez déterminer les options disponibles.",
|
||||
"selectMailbox": "Sélectionner une boîte...",
|
||||
"source": "source",
|
||||
"startImport": "Importer",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Importa file email in un account locale (NoSync). Per file più grandi, usa la CLI.",
|
||||
"detectedFolder": "Rilevato",
|
||||
"detectedFrom": "Rilevato da",
|
||||
"dropHere": "Trascina i file .eml / .mbox qui",
|
||||
"dropHere": "Trascina i file .eml / .mbox / .pst qui",
|
||||
"failed": "Importazione fallita",
|
||||
"failedCount": "{{count}} falliti",
|
||||
"failedDetails": "Elementi falliti",
|
||||
"folder": "Cartella",
|
||||
"folderMethod": "2. Scegli il metodo della cartella",
|
||||
"folderMethodDesc": "Come determinare la cartella di posta di destinazione?",
|
||||
"folderStructure": "2. Struttura delle cartelle",
|
||||
"importHistory": "Cronologia importazioni",
|
||||
"limits": "Max: EML 100 MB · MBOX 1 GB. File più grandi → CLI.",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. File più grandi → CLI.",
|
||||
"modeCustom": "Inserisci un nome cartella personalizzato",
|
||||
"modeCustomDesc": "Digita manualmente il nome della cartella di destinazione.",
|
||||
"modeExisting": "Scegli tra le caselle esistenti",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "o clicca per sfogliare",
|
||||
"processed": "{{current}} / {{total}} elaborati",
|
||||
"processing": "Elaborazione…",
|
||||
"pstFolderDesc": "Il file PST contiene la propria struttura di cartelle (es. Posta in arrivo, Elementi inviati, ecc.). Le cartelle verranno create automaticamente durante l'importazione.",
|
||||
"pstFolders": "La struttura delle cartelle PST verrà preservata durante l'importazione",
|
||||
"searchAccount": "Cerca account...",
|
||||
"searchMailbox": "Cerca caselle postali...",
|
||||
"selectAccount": "Seleziona un account",
|
||||
"selectAccountFirst": "Seleziona prima un account.",
|
||||
"selectFileFirst": "Seleziona prima un file per determinare le opzioni disponibili.",
|
||||
"selectMailbox": "Seleziona una casella...",
|
||||
"source": "origine",
|
||||
"startImport": "Importa",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "NoSyncローカルアカウントにメールファイルをインポートします。大容量ファイルはCLIを使用してください。",
|
||||
"detectedFolder": "放出演出",
|
||||
"detectedFrom": "検出元:",
|
||||
"dropHere": "ここに .eml / .mbox ファイルをドロップ",
|
||||
"dropHere": "ここに .eml / .mbox / .pst 文件をドロップ",
|
||||
"failed": "インポート失敗",
|
||||
"failedCount": "{{count}} 件の失敗",
|
||||
"failedDetails": "失敗したアイテム",
|
||||
"folder": "フォルダ",
|
||||
"folderMethod": "2. フォルダ指定方法の選択",
|
||||
"folderMethodDesc": "インポート先のフォルダをどのように決定しますか?",
|
||||
"folderStructure": "2. フォルダ構造",
|
||||
"importHistory": "インポート履歴",
|
||||
"limits": "上限: EML 100 MB · MBOX 1 GB。これ以上のサイズは → CLIへ。",
|
||||
"limits": "上限: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB。これ以上のサイズは → CLIへ。",
|
||||
"modeCustom": "カスタムフォルダ名を入力",
|
||||
"modeCustomDesc": "インポート先のフォルダ名を手動で入力します。",
|
||||
"modeExisting": "既存のメールボックスから選択",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "またはクリックしてファイルを選択",
|
||||
"processed": "{{current}} / {{total}} 件を処理済み",
|
||||
"processing": "処理中…",
|
||||
"pstFolderDesc": "PSTファイルには独自のフォルダ構造(受信トレイ、送信済みアイテムなど)が含まれています。インポート時にフォルダが自動的に作成されます。",
|
||||
"pstFolders": "インポート中、PSTファイルのフォルダ構造は維持されます",
|
||||
"searchAccount": "アカウントを検索...",
|
||||
"searchMailbox": "メールボックスを検索...",
|
||||
"selectAccount": "アカウントを選択",
|
||||
"selectAccountFirst": "最初にアカウントを選択してください。",
|
||||
"selectFileFirst": "利用可能なオプションを確認するには、最初にファイルを選択してください。",
|
||||
"selectMailbox": "メールボックスを選択...",
|
||||
"source": "ソース",
|
||||
"startImport": "インポート",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "로컬 계정(NoSync)으로 이메일 파일을 가져옵니다. 대용량 파일은 CLI를 사용하세요.",
|
||||
"detectedFolder": "감지됨",
|
||||
"detectedFrom": "감지 대상:",
|
||||
"dropHere": "여기에 .eml / .mbox 파일 끌어놓기",
|
||||
"dropHere": "여기에 .eml / .mbox / .pst 파일 끌어놓기",
|
||||
"failed": "가져오기 실패",
|
||||
"failedCount": "{{count}}개 실패",
|
||||
"failedDetails": "실패한 항목",
|
||||
"folder": "폴더",
|
||||
"folderMethod": "2. 폴더 지정 방식 선택",
|
||||
"folderMethodDesc": "가져올 메일 폴더를 어떻게 결정하시겠습니까?",
|
||||
"folderStructure": "2. 폴더 구조",
|
||||
"importHistory": "가져오기 기록",
|
||||
"limits": "제한: EML 100 MB · MBOX 1 GB. 더 큰 파일은 → CLI 사용.",
|
||||
"limits": "제한: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. 더 큰 파일은 → CLI 사용.",
|
||||
"modeCustom": "사용자 지정 폴더 이름 입력",
|
||||
"modeCustomDesc": "가져올 메일 폴더 이름을 수동으로 입력합니다.",
|
||||
"modeExisting": "기존 편지함에서 선택",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "또는 클릭하여 찾아보기",
|
||||
"processed": "{{current}} / {{total}} 처리됨",
|
||||
"processing": "처리 중…",
|
||||
"pstFolderDesc": "PST 파일은 자체 폴더 구조(예: 받은 편지함, 보낸 편지함 등)를 포함하고 있습니다. 가져오기 중에 폴더가 자동으로 생성됩니다.",
|
||||
"pstFolders": "가져오기 중에 PST 폴더 구조가 유지됩니다",
|
||||
"searchAccount": "계정 검색...",
|
||||
"searchMailbox": "편지함 검색...",
|
||||
"selectAccount": "계정 선택",
|
||||
"selectAccountFirst": "계정을 먼저 선택해 주세요.",
|
||||
"selectFileFirst": "사용 가능한 옵션을 확인하려면 먼저 파일을 선택해 주세요.",
|
||||
"selectMailbox": "편지함 선택...",
|
||||
"source": "소스",
|
||||
"startImport": "가져오기",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Importeer e-mailbestanden in een lokaal account (NoSync). Gebruik de CLI voor grotere bestanden.",
|
||||
"detectedFolder": "Gedetecteerd",
|
||||
"detectedFrom": "Gedetecteerd uit",
|
||||
"dropHere": "Sleep .eml / .mbox bestanden hierheen",
|
||||
"dropHere": "Sleep .eml / .mbox / .pst bestanden hierheen",
|
||||
"failed": "Import mislukt",
|
||||
"failedCount": "{{count}} mislukt",
|
||||
"failedDetails": "Mislukte items",
|
||||
"folder": "Map",
|
||||
"folderMethod": "2. Kies mapmethode",
|
||||
"folderMethodDesc": "Hoe moet de doelmap voor e-mail worden bepaald?",
|
||||
"folderStructure": "2. Mapstructuur",
|
||||
"importHistory": "Importgeschiedenis",
|
||||
"limits": "Max: EML 100 MB · MBOX 1 GB. Grotere bestanden → CLI.",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Grotere bestanden → CLI.",
|
||||
"modeCustom": "Voer een aangepaste mapnaam in",
|
||||
"modeCustomDesc": "Typ handmatig de naam van de doelmap.",
|
||||
"modeExisting": "Kies uit bestaande mailboxen",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "of klik om te bladeren",
|
||||
"processed": "{{current}} / {{total}} verwerkt",
|
||||
"processing": "Verwerken…",
|
||||
"pstFolderDesc": "Het PST-bestand bevat een eigen mapstructuur (bijv. Postvak IN, Verzonden items, enz.). Mappen worden automatisch aangemaakt tijdens de import.",
|
||||
"pstFolders": "De PST-mapstructuur blijft behouden tijdens de import",
|
||||
"searchAccount": "Accounts zoeken...",
|
||||
"searchMailbox": "Mailboxen zoeken...",
|
||||
"selectAccount": "Selecteer een account",
|
||||
"selectAccountFirst": "Selecteer eerst een account.",
|
||||
"selectFileFirst": "Selecteer eerst een bestand om de beschikbare opties te bepalen.",
|
||||
"selectMailbox": "Selecteer een mailbox...",
|
||||
"source": "bron",
|
||||
"startImport": "Importeren",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Importer e-postfiler til en lokal konto (NoSync). Bruk CLI for større filer.",
|
||||
"detectedFolder": "Registrert",
|
||||
"detectedFrom": "Registrert fra",
|
||||
"dropHere": "Slipp .eml / .mbox-filer her",
|
||||
"dropHere": "Slipp .eml / .mbox / .pst-filer her",
|
||||
"failed": "Import mislyktes",
|
||||
"failedCount": "{{count}} feilet",
|
||||
"failedDetails": "Feilede elementer",
|
||||
"folder": "Mappe",
|
||||
"folderMethod": "2. Velg mappemetode",
|
||||
"folderMethodDesc": "Hvordan skal målmappen for e-post bestemmes?",
|
||||
"folderStructure": "2. Mappestruktur",
|
||||
"importHistory": "Importhistorikk",
|
||||
"limits": "Maks: EML 100 MB · MBOX 1 GB. Større filer → CLI.",
|
||||
"limits": "Maks: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Større filer → CLI.",
|
||||
"modeCustom": "Skriv inn et egendefinert mappenavn",
|
||||
"modeCustomDesc": "Skriv inn navnet på målmappen manuelt.",
|
||||
"modeExisting": "Velg fra eksisterende postbokser",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "eller klikk for å bla gjennom",
|
||||
"processed": "{{current}} / {{total}} behandlet",
|
||||
"processing": "Behandler…",
|
||||
"pstFolderDesc": "PST-filen inneholder sin egen mappestruktur (f.eks. Innboks, Sendte elementer osv.). Mapper opprettes automatisk under importen.",
|
||||
"pstFolders": "PST-mappestrukturen vil bli bevart under importen",
|
||||
"searchAccount": "Søk etter kontoer...",
|
||||
"searchMailbox": "Søk etter postbokser...",
|
||||
"selectAccount": "Velg en konto",
|
||||
"selectAccountFirst": "Velg en konto først.",
|
||||
"selectFileFirst": "Velg en fil først for å se tilgjengelige alternativer.",
|
||||
"selectMailbox": "Velg en postboks...",
|
||||
"source": "kilde",
|
||||
"startImport": "Importer",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Importuj pliki e-mail do konta lokalnego (NoSync). W przypadku większych plików użyj CLI.",
|
||||
"detectedFolder": "Wykryto",
|
||||
"detectedFrom": "Wykryto z",
|
||||
"dropHere": "Upuść pliki .eml / .mbox tutaj",
|
||||
"dropHere": "Upuść pliki .eml / .mbox / .pst tutaj",
|
||||
"failed": "Import nie powiódł się",
|
||||
"failedCount": "Niepowodzenie: {{count}}",
|
||||
"failedDetails": "Nieudane elementy",
|
||||
"folder": "Folder",
|
||||
"folderMethod": "2. Wybierz metodę folderu",
|
||||
"folderMethodDesc": "Jak ma zostać określony docelowy folder poczty?",
|
||||
"folderStructure": "2. Struktura folderów",
|
||||
"importHistory": "Historia importu",
|
||||
"limits": "Maks: EML 100 MB · MBOX 1 GB. Większe pliki → CLI.",
|
||||
"limits": "Maks: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Większe pliki → CLI.",
|
||||
"modeCustom": "Wprowadź własną nazwę folderu",
|
||||
"modeCustomDesc": "Ręcznie wpisz nazwę docelowego folderu poczty.",
|
||||
"modeExisting": "Wybierz z istniejących skrzynek",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "lub kliknij, aby przeglądać",
|
||||
"processed": "Przetworzono: {{current}} / {{total}}",
|
||||
"processing": "Przetwarzanie…",
|
||||
"pstFolderDesc": "Plik PST zawiera własną strukturę folderów (np. Skrzynka odbiorcza, Elementy wysłane itp.). Foldery zostaną utworzone automatycznie podczas importu.",
|
||||
"pstFolders": "Struktura folderów PST zostanie zachowana podczas importu",
|
||||
"searchAccount": "Szukaj kont...",
|
||||
"searchMailbox": "Szukaj skrzynek...",
|
||||
"selectAccount": "Wybierz konto",
|
||||
"selectAccountFirst": "Najpierw wybierz konto.",
|
||||
"selectFileFirst": "Najpierw wybierz plik, aby określić dostępne opcje.",
|
||||
"selectMailbox": "Wybierz skrzynkę...",
|
||||
"source": "źródło",
|
||||
"startImport": "Importuj",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Importar arquivos de e-mail para uma conta local (NoSync). Para arquivos maiores, use a CLI.",
|
||||
"detectedFolder": "Detectado",
|
||||
"detectedFrom": "Detectado de",
|
||||
"dropHere": "Solte arquivos .eml / .mbox aqui",
|
||||
"dropHere": "Solte arquivos .eml / .mbox / .pst aqui",
|
||||
"failed": "Falha na importação",
|
||||
"failedCount": "{{count}} falharam",
|
||||
"failedDetails": "Itens com falha",
|
||||
"folder": "Pasta",
|
||||
"folderMethod": "2. Escolher método de pasta",
|
||||
"folderMethodDesc": "Como a pasta de e-mail de destino deve ser determinada?",
|
||||
"folderStructure": "2. Estrutura de pastas",
|
||||
"importHistory": "Histórico de importação",
|
||||
"limits": "Máx: EML 100 MB · MBOX 1 GB. Arquivos maiores → CLI.",
|
||||
"limits": "Máx: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Arquivos maiores → CLI.",
|
||||
"modeCustom": "Digitar um nome de pasta personalizado",
|
||||
"modeCustomDesc": "Digite manualmente o nome da pasta de e-mail de destino.",
|
||||
"modeExisting": "Escolher a partir de caixas existentes",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "ou clique para navegar",
|
||||
"processed": "{{current}} / {{total}} processados",
|
||||
"processing": "Processando…",
|
||||
"pstFolderDesc": "O arquivo PST contém sua própria estrutura de pastas (por exemplo, Caixa de entrada, Itens enviados, etc.). As pastas serão criadas automaticamente durante a importação.",
|
||||
"pstFolders": "A estrutura de pastas PST será preservada durante a importação",
|
||||
"searchAccount": "Buscar contas...",
|
||||
"searchMailbox": "Buscar caixas de correio...",
|
||||
"selectAccount": "Selecionar uma conta",
|
||||
"selectAccountFirst": "Selecione uma conta primeiro.",
|
||||
"selectFileFirst": "Selecione um arquivo primeiro para determinar as opções disponíveis.",
|
||||
"selectMailbox": "Selecionar caixa de correio...",
|
||||
"source": "origem",
|
||||
"startImport": "Importar",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Импорт файлов писем в локальный аккаунт (NoSync). Для больших файлов используйте CLI.",
|
||||
"detectedFolder": "Обнаружено",
|
||||
"detectedFrom": "Обнаружено из",
|
||||
"dropHere": "Перетащите файлы .eml / .mbox сюда",
|
||||
"dropHere": "Перетащите файлы .eml / .mbox / .pst сюда",
|
||||
"failed": "Ошибка импорта",
|
||||
"failedCount": "Ошибок: {{count}}",
|
||||
"failedDetails": "Неудачные элементы",
|
||||
"folder": "Папка",
|
||||
"folderMethod": "2. Выберите метод определения папки",
|
||||
"folderMethodDesc": "Как следует определять целевую папку для писем?",
|
||||
"folderStructure": "2. Структура папок",
|
||||
"importHistory": "История импорта",
|
||||
"limits": "Макс: EML 100 МБ · MBOX 1 ГБ. Для больших файлов → CLI.",
|
||||
"limits": "Макс: EML 100 МБ · MBOX {{maxMbox}} МБ · PST {{maxPst}} МБ. Для больших файлов → CLI.",
|
||||
"modeCustom": "Ввести имя папки вручную",
|
||||
"modeCustomDesc": "Введите имя целевой папки вручную.",
|
||||
"modeExisting": "Выбрать из существующих ящиков",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "или нажмите для обзора",
|
||||
"processed": "Обработано: {{current}} / {{total}}",
|
||||
"processing": "Обработка…",
|
||||
"pstFolderDesc": "Файл PST содержит собственную структуру папок (например, Входящие, Отправленные и т. д.). Папки будут созданы автоматически во время импорта.",
|
||||
"pstFolders": "Структура папок PST будет сохранена при импорте",
|
||||
"searchAccount": "Поиск аккаунтов...",
|
||||
"searchMailbox": "Поиск почтовых ящиков...",
|
||||
"selectAccount": "Выберите аккаунт",
|
||||
"selectAccountFirst": "Сначала выберите аккаунт.",
|
||||
"selectFileFirst": "Сначала выберите файл, чтобы определить доступные параметры.",
|
||||
"selectMailbox": "Выберите почтовый ящик...",
|
||||
"source": "источник",
|
||||
"startImport": "Импортировать",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "Importera e-postfiler till ett lokalt konto (NoSync). Använd CLI för större filer.",
|
||||
"detectedFolder": "Identifierad",
|
||||
"detectedFrom": "Identifierad från",
|
||||
"dropHere": "Släpp .eml / .mbox-filer här",
|
||||
"dropHere": "Släpp .eml / .mbox / .pst-filer här",
|
||||
"failed": "Import misslyckades",
|
||||
"failedCount": "{{count}} misslyckades",
|
||||
"failedDetails": "Misslyckade objekt",
|
||||
"folder": "Mapp",
|
||||
"folderMethod": "2. Välj mappemetod",
|
||||
"folderMethodDesc": "Hur ska målmappen for e-post bestämmas?",
|
||||
"folderStructure": "2. Mappstruktur",
|
||||
"importHistory": "Importhistorik",
|
||||
"limits": "Max: EML 100 MB · MBOX 1 GB. Större filer → CLI.",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Større filer → CLI.",
|
||||
"modeCustom": "Ange ett anpassat mappnamn",
|
||||
"modeCustomDesc": "Ange namnet på målmappen manuellt.",
|
||||
"modeExisting": "Välj från befintliga brevlådor",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "eller klicka för att bläddra",
|
||||
"processed": "{{current}} / {{total}} behandlade",
|
||||
"processing": "Behandlar…",
|
||||
"pstFolderDesc": "PST-filen innehåller sin egen mappstruktur (t.ex. Inborgen, Skickat osv.). Mappar skapas automatiskt under importen.",
|
||||
"pstFolders": "PST-mappstrukturen kommer att bevaras under importen",
|
||||
"searchAccount": "Sök konton...",
|
||||
"searchMailbox": "Sök brevlådor...",
|
||||
"selectAccount": "Välj ett konto",
|
||||
"selectAccountFirst": "Välj ett konto först.",
|
||||
"selectFileFirst": "Välj en fil först för att se tillgängliga alternativ.",
|
||||
"selectMailbox": "Välj en brevlåda...",
|
||||
"source": "källa",
|
||||
"startImport": "Importera",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "將郵件檔案匯入至本地帳戶 (NoSync)。大檔案請使用 CLI 命令行工具。",
|
||||
"detectedFolder": "已識別",
|
||||
"detectedFrom": "識別自",
|
||||
"dropHere": "將 .eml / .mbox 檔案拖曳到此處",
|
||||
"dropHere": "將 .eml / .mbox / .pst 檔案拖曳到此處",
|
||||
"failed": "匯入失敗",
|
||||
"failedCount": "{{count}} 個失敗",
|
||||
"failedDetails": "失敗詳情",
|
||||
"folder": "資料夾",
|
||||
"folderMethod": "2. 選擇資料夾比對策略",
|
||||
"folderMethodDesc": "如何確定匯入 Target 郵件資料夾?",
|
||||
"folderStructure": "2. 資料夾結構",
|
||||
"importHistory": "匯入歷史",
|
||||
"limits": "限制:EML 100 MB · MBOX 1 GB。超過限制請使用 CLI。",
|
||||
"limits": "限制: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB。超過限制請使用 CLI。",
|
||||
"modeCustom": "指定自訂資料夾名稱",
|
||||
"modeCustomDesc": "手動輸入目標郵件資料夾的名稱。",
|
||||
"modeExisting": "從現有郵箱中選擇",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "或點擊瀏覽檔案",
|
||||
"processed": "已處理 {{current}} / {{total}}",
|
||||
"processing": "正在處理…",
|
||||
"pstFolderDesc": "PST 檔案包含其自身的資料夾結構(例如:收件箱、已發送郵件等)。匯入過程中將自動建立這些資料夾。",
|
||||
"pstFolders": "匯入過程中將保留 PST 資料夾結構",
|
||||
"searchAccount": "搜尋帳戶...",
|
||||
"searchMailbox": "搜尋郵箱...",
|
||||
"selectAccount": "選擇帳戶",
|
||||
"selectAccountFirst": "請先選擇一個帳戶。",
|
||||
"selectFileFirst": "請先選擇檔案以確定可用選項。",
|
||||
"selectMailbox": "選擇郵箱...",
|
||||
"source": "來源",
|
||||
"startImport": "開始匯入",
|
||||
|
||||
@@ -596,15 +596,16 @@
|
||||
"description": "将邮件文件导入至本地账户 (NoSync)。大文件请使用 CLI 命令行工具。",
|
||||
"detectedFolder": "已识别",
|
||||
"detectedFrom": "识别自",
|
||||
"dropHere": "将 .eml / .mbox 文件拖拽到此处",
|
||||
"dropHere": "将 .eml / .mbox / .pst 文件拖拽到此处",
|
||||
"failed": "导入失败",
|
||||
"failedCount": "{{count}} 个失败",
|
||||
"failedDetails": "失败详情",
|
||||
"folder": "文件夹",
|
||||
"folderMethod": "2. 选择文件夹匹配策略",
|
||||
"folderMethodDesc": "如何确定导入的目标邮件文件夹?",
|
||||
"folderStructure": "2. 文件夹结构",
|
||||
"importHistory": "导入历史",
|
||||
"limits": "限制:EML 100 MB · MBOX 1 GB。超过限制请使用 CLI。",
|
||||
"limits": "限制: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB。超过限制请使用 CLI。",
|
||||
"modeCustom": "指定自定义文件夹名称",
|
||||
"modeCustomDesc": "手动输入目标邮件文件夹的名称。",
|
||||
"modeExisting": "从现有邮箱中选择",
|
||||
@@ -618,10 +619,13 @@
|
||||
"orClick": "或点击浏览文件",
|
||||
"processed": "已处理 {{current}} / {{total}}",
|
||||
"processing": "正在处理…",
|
||||
"pstFolderDesc": "PST 文件包含其自身的文件夹结构(例如:收件箱、已发送邮件等)。导入过程中将自动创建这些文件夹。",
|
||||
"pstFolders": "导入过程中将保留 PST 文件夹结构",
|
||||
"searchAccount": "搜索账户...",
|
||||
"searchMailbox": "搜索邮箱...",
|
||||
"selectAccount": "选择账户",
|
||||
"selectAccountFirst": "请先选择一个账户。",
|
||||
"selectFileFirst": "请先选择文件以确定可用选项。",
|
||||
"selectMailbox": "选择邮箱...",
|
||||
"source": "来源",
|
||||
"startImport": "开始导入",
|
||||
|
||||
Reference in New Issue
Block a user