From 40ae2d49d4a19bf318f5b322b7f9c7894d7e895e Mon Sep 17 00:00:00 2001 From: rustmailer Date: Sun, 28 Jun 2026 09:43:50 +0800 Subject: [PATCH] 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) --- Cargo.lock | 8 +- crates/cli/Cargo.toml | 4 - crates/cli/src/pst/mod.rs | 264 +--------- crates/core/Cargo.toml | 4 + crates/core/src/import/mod.rs | 126 ++++- .../src/import}/pst/encoding/mod.rs | 4 +- crates/core/src/import/pst/mod.rs | 486 ++++++++++++++++++ crates/core/src/settings/cli.rs | 19 + crates/core/src/settings/mod.rs | 6 + crates/server/src/rest/api/import.rs | 52 +- web/src/api/system/api.ts | 4 + web/src/features/import/folder-hint.ts | 8 +- web/src/features/import/index.tsx | 95 +++- web/src/locales/ar.json | 8 +- web/src/locales/da.json | 8 +- web/src/locales/de.json | 8 +- web/src/locales/en.json | 8 +- web/src/locales/es.json | 8 +- web/src/locales/fi.json | 8 +- web/src/locales/fr.json | 8 +- web/src/locales/it.json | 8 +- web/src/locales/jp.json | 8 +- web/src/locales/ko.json | 8 +- web/src/locales/nl.json | 8 +- web/src/locales/no.json | 8 +- web/src/locales/pl.json | 8 +- web/src/locales/pt.json | 8 +- web/src/locales/ru.json | 8 +- web/src/locales/sv.json | 8 +- web/src/locales/zh-tw.json | 8 +- web/src/locales/zh.json | 8 +- 31 files changed, 882 insertions(+), 342 deletions(-) rename crates/{cli/src => core/src/import}/pst/encoding/mod.rs (97%) create mode 100644 crates/core/src/import/pst/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 594aad9..2299617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 08de615..ac7f72e 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -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 \ No newline at end of file diff --git a/crates/cli/src/pst/mod.rs b/crates/cli/src/pst/mod.rs index 45e31ae..9ca53b7 100644 --- a/crates/cli/src/pst/mod.rs +++ b/crates/cli/src/pst/mod.rs @@ -16,21 +16,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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, - pub subject: Option, - pub from: Option, - pub to: Option>, - pub cc: Option>, - pub bcc: Option>, - pub html: Option, - pub text: Option, - pub in_reply_to: Option, -} - -#[derive(Debug, Default)] -pub struct EmailAttachment { - pub name: Option, - pub mime_type: Option, - pub data: Option>, -} - 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) -> Option { - 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::>()); - } - if !cc.is_empty() { - builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::>()); - } - if !bcc.is_empty() { - builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::>()); - } - - 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 { - 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) -> (Vec, Vec, Vec) { - 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 { - props.get(0x0037).and_then(|val| decode_subject(val)) -} - -fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option { - properties - .get(prop_id) - .and_then(|value| extract_string(value)) -} - -fn extract_attachment_string_property( - properties: &AttachmentProperties, - prop_id: u16, -) -> Option { - properties - .get(prop_id) - .and_then(|value| extract_string(value)) -} - -fn extract_string(value: &PropertyValue) -> Option { - match value { - PropertyValue::String8(value) => Some(value.to_string()), - PropertyValue::Unicode(value) => Some(value.to_string()), - _ => None, - } -} - -fn extract_text(properties: &MessageProperties) -> Option { - 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 { - 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 { - for &prop_id in prop_ids { - if let Some(PropertyValue::Time(value)) = properties.get(prop_id) { - return Some(*value); - } - } - None -} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index f90da35..801c630 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -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 diff --git a/crates/core/src/import/mod.rs b/crates/core/src/import/mod.rs index c98c6a7..bd8db2a 100644 --- a/crates/core/src/import/mod.rs +++ b/crates/core/src/import/mod.rs @@ -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 { pub enum FileFormat { Eml, Mbox, + Pst, } pub fn detect_format(bytes: &[u8], file_name: &str) -> Option { + // 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 { 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 { } /// Resolve or create a mailbox/folder for the given account. -fn resolve_mailbox(account: &AccountModel, folder: &str) -> BichonResult { +pub(super) fn resolve_mailbox(account: &AccountModel, folder: &str) -> BichonResult { 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 { } } +/// 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 { + 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 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 = 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, diff --git a/crates/cli/src/pst/encoding/mod.rs b/crates/core/src/import/pst/encoding/mod.rs similarity index 97% rename from crates/cli/src/pst/encoding/mod.rs rename to crates/core/src/import/pst/encoding/mod.rs index 1e0830f..af61640 100644 --- a/crates/cli/src/pst/encoding/mod.rs +++ b/crates/core/src/import/pst/encoding/mod.rs @@ -16,8 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - -use compressed_rtf::*; use outlook_pst::ltp::prop_context::PropertyValue; pub fn decode_subject(value: &PropertyValue) -> Option { @@ -60,5 +58,5 @@ pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option { } pub fn decode_rtf_compressed(buffer: &[u8]) -> Option { - decompress_rtf(buffer).ok() + compressed_rtf::decompress_rtf(buffer).ok() } diff --git a/crates/core/src/import/pst/mod.rs b/crates/core/src/import/pst/mod.rs new file mode 100644 index 0000000..2fe7992 --- /dev/null +++ b/crates/core/src/import/pst/mod.rs @@ -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 . + +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) -> Option { + 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::>()); + } + if !cc.is_empty() { + builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::>()); + } + if !bcc.is_empty() { + builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::>()); + } + + 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 { + 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) -> (Vec, Vec, Vec) { + 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 { + props.get(0x0037).and_then(|val| encoding::decode_subject(val)) +} + +fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option { + properties + .get(prop_id) + .and_then(|value| extract_string(value)) +} + +fn extract_attachment_string_property( + properties: &AttachmentProperties, + prop_id: u16, +) -> Option { + properties + .get(prop_id) + .and_then(|value| extract_string(value)) +} + +fn extract_string(value: &PropertyValue) -> Option { + match value { + PropertyValue::String8(value) => Some(value.to_string()), + PropertyValue::Unicode(value) => Some(value.to_string()), + _ => None, + } +} + +fn extract_text(properties: &MessageProperties) -> Option { + 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 { + 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 { + 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 { + 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) -> 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( + folder: &Rc, + parent_path: &str, + account_id: u64, + total: usize, + success_count: &mut usize, + failed_details: &mut Vec, + 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( + folder: &Rc, + parent_path: &str, + account_id: u64, + total: usize, + success_count: &mut usize, + failed_details: &mut Vec, + 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, + ); + } + } + } + } +} diff --git a/crates/core/src/settings/cli.rs b/crates/core/src/settings/cli.rs index 0dcc472..1e6c0e7 100644 --- a/crates/core/src/settings/cli.rs +++ b/crates/core/src/settings/cli.rs @@ -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 { diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index e2a3d5c..6e843e4 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -67,6 +67,10 @@ pub struct SystemConfigurations { pub bichon_oidc_redirect_uri: Option, 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, } } } diff --git a/crates/server/src/rest/api/import.rs b/crates/server/src/rest/api/import.rs index 622cf2a..5ab5358 100644 --- a/crates/server/src/rest/api/import.rs +++ b/crates/server/src/rest/api/import.rs @@ -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, 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 ))?; } diff --git a/web/src/api/system/api.ts b/web/src/api/system/api.ts index 21645dc..20cad67 100644 --- a/web/src/api/system/api.ts +++ b/web/src/api/system/api.ts @@ -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 () => { diff --git a/web/src/features/import/folder-hint.ts b/web/src/features/import/folder-hint.ts index 1f34a8b..3618d55 100644 --- a/web/src/features/import/folder-hint.ts +++ b/web/src/features/import/folder-hint.ts @@ -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 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; } diff --git a/web/src/features/import/index.tsx b/web/src/features/import/index.tsx index 9ae7ea9..0376a35 100644 --- a/web/src/features/import/index.tsx +++ b/web/src/features/import/index.tsx @@ -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(''); - const [folderMode, setFolderMode] = useState('header'); + const [folderMode, setFolderMode] = useState(''); const [folder, setFolder] = useState('INBOX'); const [files, setFiles] = useState([]); 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(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() {

- {t('import.title', 'Import EML / MBOX')} + {t('import.title', 'Import EML / MBOX / PST')}

{t('import.description', 'Import email files into a NoSync account. For larger files, use the CLI.')} @@ -350,13 +389,20 @@ export default function ImportPage() { - {t('import.folderMethod', '2. Choose folder method')} + {isPstSelected + ? t('import.folderStructure', '2. Folder structure') + : t('import.folderMethod', '2. Choose folder method')} - {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?')} + {!isPstSelected && ( handleModeChange(v as FolderMode)} @@ -516,6 +562,7 @@ export default function ImportPage() {

+ )} @@ -526,7 +573,11 @@ export default function ImportPage() { {t('import.chooseFiles', '3. Choose files')} - {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) + })} @@ -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() { >

- {t('import.dropHere', 'Drop .eml / .mbox files here')} + {t('import.dropHere', 'Drop .eml / .mbox / .pst files here')}

{t('import.orClick', 'or click to browse')} @@ -677,7 +728,9 @@ export default function ImportPage() { {/* Import button */}

- {t('import.willImportTo', 'Will import to')}: {effectiveFolder} + {isPstSelected + ? t('import.pstFolders', 'PST folder structure will be preserved during import') + : (<>{t('import.willImportTo', 'Will import to')}: {effectiveFolder})}