use crate::error::{Result, XbergError};
use crate::types::{EmailAttachment, EmailExtractionResult, ProcessingWarning};
use std::borrow::Cow;
use std::collections::HashMap;
#[cfg(feature = "email")]
use outlook_pst::{
ltp::{prop_context::PropertyValue, table_context::TableContext},
messaging::{folder::Folder as PstFolder, message::Message as PstMessage, store::EntryId},
ndb::node_id::{NID_ROOT_FOLDER, NodeId},
};
#[cfg(feature = "email")]
use std::rc::Rc;
#[cfg(feature = "email")]
type PstFolderSeed = (Rc<dyn PstFolder>, u32, String);
#[cfg(feature = "email")]
const MAX_TABLE_ROWS: usize = 100_000;
#[cfg(feature = "email")]
fn collect_row_ids(table: &dyn TableContext) -> (Vec<u32>, bool) {
let mut ids = Vec::new();
for row in table.rows_matrix() {
if ids.len() >= MAX_TABLE_ROWS {
return (ids, true);
}
ids.push(u32::from(row.id()));
}
(ids, false)
}
#[cfg(feature = "email")]
fn non_ipm_top_level_ids(top_level_ids: &[u32], ipm_node_id: Option<u32>) -> Vec<u32> {
top_level_ids
.iter()
.copied()
.filter(|id| Some(*id) != ipm_node_id)
.collect()
}
#[cfg(all(feature = "email", not(target_arch = "wasm32")))]
pub(crate) fn extract_pst_messages(pst_data: &[u8]) -> Result<(Vec<EmailExtractionResult>, Vec<ProcessingWarning>)> {
use std::io::Write;
let mut temp_file = tempfile::Builder::new()
.prefix("xberg_pst_")
.suffix(".tmp")
.tempfile()
.map_err(crate::XbergError::from)?;
temp_file.write_all(pst_data).map_err(crate::XbergError::from)?;
temp_file.flush().map_err(crate::XbergError::from)?;
let (messages, warnings) = extract_from_path(temp_file.path())?;
Ok((messages, warnings))
}
#[cfg(all(feature = "email", target_arch = "wasm32"))]
pub(crate) fn extract_pst_messages(_pst_data: &[u8]) -> Result<(Vec<EmailExtractionResult>, Vec<ProcessingWarning>)> {
Err(XbergError::Validation {
message: "PST extraction is not supported on WebAssembly targets".to_string(),
source: None,
})
}
#[cfg(all(feature = "email", feature = "tokio-runtime"))]
pub(crate) fn extract_pst_from_path(
path: &std::path::Path,
) -> Result<(Vec<EmailExtractionResult>, Vec<ProcessingWarning>)> {
extract_from_path(path)
}
#[cfg(feature = "email")]
fn extract_from_path(path: &std::path::Path) -> Result<(Vec<EmailExtractionResult>, Vec<ProcessingWarning>)> {
let store = outlook_pst::open_store(path).map_err(|e| XbergError::Validation {
message: format!("Failed to open PST file: {e}"),
source: None,
})?;
Ok(extract_from_store(store.as_ref()))
}
#[cfg(feature = "email")]
fn extract_from_store(
store: &dyn outlook_pst::messaging::store::Store,
) -> (Vec<EmailExtractionResult>, Vec<ProcessingWarning>) {
let mut warnings = Vec::new();
let ipm_entry = match store.properties().ipm_sub_tree_entry_id() {
Ok(e) => e,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!("Failed to locate IPM (mail) sub-tree in PST store: {e}")),
});
return (Vec::new(), warnings);
}
};
let root_folder = match store.open_folder(&ipm_entry) {
Ok(f) => f,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!("Failed to open IPM (mail) sub-tree root folder: {e}")),
});
return (Vec::new(), warnings);
}
};
let root_name = root_folder
.properties()
.display_name()
.unwrap_or_else(|_| "Top of Personal Folders".to_string());
let ipm_node_id = u32::from(ipm_entry.node_id());
let mut seeds: Vec<PstFolderSeed> = vec![(root_folder, 0, root_name)];
let (non_ipm_seeds, mut discovery_warnings) = discover_non_ipm_top_level_folders(store, ipm_node_id);
seeds.extend(non_ipm_seeds);
warnings.append(&mut discovery_warnings);
let (messages, mut traversal_warnings) = walk_folder_tree(store, seeds);
warnings.append(&mut traversal_warnings);
(messages, warnings)
}
#[cfg(feature = "email")]
fn discover_non_ipm_top_level_folders(
store: &dyn outlook_pst::messaging::store::Store,
ipm_node_id: u32,
) -> (Vec<PstFolderSeed>, Vec<ProcessingWarning>) {
let mut seeds = Vec::new();
let mut warnings = Vec::new();
let root_entry_id = match store.properties().make_entry_id(NID_ROOT_FOLDER) {
Ok(e) => e,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!(
"Failed to build entry ID for PST root folder while enumerating non-IPM top-level folders: {e}"
)),
});
return (seeds, warnings);
}
};
let root_folder = match store.open_folder(&root_entry_id) {
Ok(f) => f,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!(
"Failed to open PST root folder while enumerating non-IPM top-level folders: {e}"
)),
});
return (seeds, warnings);
}
};
let root_table = match root_folder.hierarchy_table() {
Some(t) => t.clone(),
None => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(
"PST root folder has no hierarchy table; cannot enumerate non-IPM top-level folders".to_string(),
),
});
return (seeds, warnings);
}
};
let (top_level_ids, truncated) = collect_row_ids(root_table.as_ref());
if truncated {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!(
"PST store root exceeds the maximum top-level folder limit ({MAX_TABLE_ROWS}); remaining top-level folders skipped"
)),
});
}
for id in non_ipm_top_level_ids(&top_level_ids, Some(ipm_node_id)) {
let node = NodeId::from(id);
let entry_id = match store.properties().make_entry_id(node) {
Ok(e) => e,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!(
"Failed to create entry ID for non-IPM top-level folder node {:?}: {}; folder skipped",
node, e
)),
});
continue;
}
};
let top_folder = match store.open_folder(&entry_id) {
Ok(f) => f,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!(
"Failed to open non-IPM top-level folder (node {:?}): {}; folder skipped",
node, e
)),
});
continue;
}
};
let top_name = top_folder
.properties()
.display_name()
.unwrap_or_else(|_| format!("(unnamed non-IPM folder, node {node:?})"));
seeds.push((top_folder, 0, top_name));
}
(seeds, warnings)
}
#[cfg(feature = "email")]
fn walk_folder_tree(
store: &dyn outlook_pst::messaging::store::Store,
mut folder_stack: Vec<PstFolderSeed>,
) -> (Vec<EmailExtractionResult>, Vec<ProcessingWarning>) {
let mut messages = Vec::new();
let mut warnings = Vec::new();
while let Some((folder, depth, folder_path)) = folder_stack.pop() {
if depth > 50 {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!(
"Folder '{folder_path}' exceeds maximum traversal depth (50); subtree truncated"
)),
});
continue;
}
if let Some(contents) = folder.contents_table() {
let (ids, truncated) = collect_row_ids(contents.as_ref());
if truncated {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!(
"Folder '{folder_path}' contents table exceeds the maximum row limit ({MAX_TABLE_ROWS}); remaining messages skipped"
)),
});
}
for id in ids {
let node = NodeId::from(id);
let entry_id = match store.properties().make_entry_id(node) {
Ok(e) => e,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!(
"Failed to create entry ID for message node {:?}: {}",
node, e
)),
});
continue;
}
};
let msg = match store.open_message(&entry_id, None) {
Ok(m) => m,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!("Failed to open message {:?}: {}", entry_id, e)),
});
continue;
}
};
messages.push(extract_message_content(msg.as_ref(), &entry_id, &folder_path));
}
}
if let Some(hierarchy) = folder.hierarchy_table() {
let (ids, truncated) = collect_row_ids(hierarchy.as_ref());
if truncated {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!(
"Folder '{folder_path}' hierarchy table exceeds the maximum row limit ({MAX_TABLE_ROWS}); remaining subfolders skipped"
)),
});
}
for id in ids {
let node = NodeId::from(id);
let entry_id = match store.properties().make_entry_id(node) {
Ok(e) => e,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!("Failed to create entry ID for folder node {:?}: {}", node, e)),
});
continue;
}
};
let sub_folder = match store.open_folder(&entry_id) {
Ok(f) => f,
Err(e) => {
warnings.push(ProcessingWarning {
source: Cow::Borrowed("pst_extraction"),
message: Cow::Owned(format!("Failed to open folder {:?}: {}", entry_id, e)),
});
continue;
}
};
let sub_name = sub_folder
.properties()
.display_name()
.unwrap_or_else(|_| format!("(unnamed folder, node {node:?})"));
let sub_path = format!("{folder_path}/{sub_name}");
folder_stack.push((sub_folder, depth + 1, sub_path));
}
}
}
(messages, warnings)
}
#[cfg(feature = "email")]
fn extract_message_content(message: &dyn PstMessage, entry_id: &EntryId, folder_path: &str) -> EmailExtractionResult {
let props = message.properties();
let subject = get_str_prop(props, 0x0037);
let sender_name = get_str_prop(props, 0x0C1A);
let sender_email = get_str_prop(props, 0x0C1F);
let from_email = sender_email.or(sender_name);
let plain_text = get_str_prop(props, 0x1000);
let html_content = get_str_prop(props, 0x1013);
let rtf_body = get_binary_prop(props, 0x1009)
.and_then(|data| super::email::decompress_rtf_compressed(&data))
.map(|rtf| super::email::strip_rtf_to_plain_text(&rtf))
.filter(|s| !s.is_empty());
let content = resolve_pst_body(plain_text.as_deref(), html_content.as_deref(), rtf_body.as_deref());
let date = props.get(0x0E06).and_then(|v| {
if let PropertyValue::Time(ft) = v {
Some(windows_filetime_to_string(*ft))
} else {
None
}
});
let record_key = entry_id.record_key();
let node_id_bytes = u32::from(entry_id.node_id()).to_le_bytes();
let entry_id_hex: String = std::iter::repeat_n(0u8, 4)
.chain(record_key.iter().copied())
.chain(node_id_bytes.iter().copied())
.map(|b| format!("{b:02X}"))
.collect();
let mut to_emails: Vec<String> = Vec::new();
let mut cc_emails: Vec<String> = Vec::new();
let mut bcc_emails: Vec<String> = Vec::new();
if let Some(recipient_table) = message.recipient_table() {
let context = recipient_table.context();
let col_defs: Vec<(u16, _)> = context.columns().iter().map(|c| (c.prop_id(), c.prop_type())).collect();
for row in recipient_table.rows_matrix() {
let Ok(col_values) = row.columns(context) else {
continue;
};
let mut recipient_type: i32 = 1;
let mut display_name: Option<String> = None;
let mut smtp_email: Option<String> = None;
for ((prop_id, prop_type), value_opt) in col_defs.iter().zip(col_values.iter()) {
let Some(value_record) = value_opt else {
continue;
};
let Ok(value) = recipient_table.read_column(value_record, *prop_type) else {
continue;
};
match prop_id {
0x0C15 => {
if let PropertyValue::Integer32(v) = value {
recipient_type = v;
}
}
0x3001 => {
display_name = prop_value_to_string(&value);
}
0x39FE | 0x3003 if smtp_email.is_none() => {
smtp_email = prop_value_to_string(&value);
}
_ => {}
}
}
let recipient = smtp_email.or(display_name).unwrap_or_default();
if recipient.is_empty() {
continue;
}
match recipient_type {
1 => to_emails.push(recipient),
2 => cc_emails.push(recipient),
3 => bcc_emails.push(recipient),
_ => {
tracing::warn!(recipient_type, "Unknown MAPI recipient type; skipping recipient");
}
}
}
}
let mut attachments: Vec<EmailAttachment> = Vec::new();
if let Some(attach_table) = message.attachment_table() {
let context = attach_table.context();
let col_defs: Vec<(u16, _)> = context.columns().iter().map(|c| (c.prop_id(), c.prop_type())).collect();
for row in attach_table.rows_matrix() {
let Ok(col_values) = row.columns(context) else {
continue;
};
let mut long_filename: Option<String> = None;
let mut short_filename: Option<String> = None;
let mut attach_data: Option<Vec<u8>> = None;
for ((prop_id, prop_type), value_opt) in col_defs.iter().zip(col_values.iter()) {
let Some(value_record) = value_opt else {
continue;
};
let Ok(value) = attach_table.read_column(value_record, *prop_type) else {
continue;
};
match prop_id {
0x3707 => long_filename = prop_value_to_string(&value),
0x3704 => short_filename = prop_value_to_string(&value),
0x3701 => {
if let PropertyValue::Binary(v) = value {
attach_data = Some(v.buffer().to_vec());
}
}
_ => {}
}
}
let filename = long_filename.or(short_filename);
let size = attach_data.as_ref().map(|d| d.len());
let mime_type = filename
.as_deref()
.and_then(|f| mime_guess::from_path(f).first())
.map(|m| m.to_string());
let is_image = mime_type.as_deref().is_some_and(|m| m.starts_with("image/"));
attachments.push(EmailAttachment {
name: filename.clone(),
filename,
mime_type,
size,
is_image,
data: attach_data.map(bytes::Bytes::from),
});
}
}
EmailExtractionResult {
subject,
from_email,
to_emails,
cc_emails,
bcc_emails,
date,
message_id: None,
plain_text,
html_content,
content,
attachments,
metadata: HashMap::from([
("entry_id".to_string(), entry_id_hex),
("folder_path".to_string(), folder_path.to_string()),
]),
}
}
#[cfg(feature = "email")]
fn get_str_prop(props: &outlook_pst::messaging::message::MessageProperties, prop_id: u16) -> Option<String> {
prop_value_to_string(props.get(prop_id)?)
}
#[cfg(feature = "email")]
fn get_binary_prop(props: &outlook_pst::messaging::message::MessageProperties, prop_id: u16) -> Option<Vec<u8>> {
match props.get(prop_id)? {
PropertyValue::Binary(v) => Some(v.buffer().to_vec()),
_ => None,
}
}
fn resolve_pst_body(plain_text: Option<&str>, html_content: Option<&str>, rtf_body: Option<&str>) -> String {
if let Some(plain) = plain_text.filter(|s| !s.is_empty()) {
plain.to_string()
} else if let Some(html) = html_content.filter(|s| !s.is_empty()) {
super::email::clean_html_content(html)
} else if let Some(rtf) = rtf_body.filter(|s| !s.is_empty()) {
rtf.to_string()
} else {
String::new()
}
}
#[cfg(feature = "email")]
fn prop_value_to_string(value: &PropertyValue) -> Option<String> {
match value {
PropertyValue::String8(v) => Some(v.to_string()),
PropertyValue::Unicode(v) => Some(v.to_string()),
PropertyValue::Binary(v) => Some(String::from_utf8_lossy(v.buffer()).into_owned()),
_ => None,
}
}
#[cfg(feature = "email")]
fn windows_filetime_to_string(filetime: i64) -> String {
use chrono::DateTime;
const EPOCH_DIFF_100NS: i64 = 116_444_736_000_000_000;
if filetime < EPOCH_DIFF_100NS {
return format!("(invalid timestamp: {})", filetime);
}
let unix_100ns = filetime - EPOCH_DIFF_100NS;
let unix_secs = unix_100ns / 10_000_000;
let nsecs = (unix_100ns % 10_000_000) * 100;
DateTime::from_timestamp(unix_secs, nsecs as u32)
.map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| format!("(invalid timestamp: {})", filetime))
}
#[cfg(not(feature = "email"))]
pub(crate) fn extract_pst_messages(_pst_data: &[u8]) -> Result<(Vec<EmailExtractionResult>, Vec<ProcessingWarning>)> {
Err(XbergError::FeatureNotEnabled {
feature: "email".to_string(),
context: Some("PST extraction requires the 'email' feature to be enabled".to_string()),
})
}
#[cfg(test)]
#[cfg(feature = "email")]
mod tests {
use super::*;
use outlook_pst::{
ltp::prop_context::PropertyValue,
messaging::folder::{Folder, FolderProperties},
messaging::store::{EntryId, Store, StoreProperties, StoreRecordKey},
ndb::node_id::NodeId,
};
use std::io;
#[test]
fn test_resolve_pst_body_prefers_plain_text_issue_152() {
let result = resolve_pst_body(Some("plain body"), Some("<p>html body</p>"), Some("rtf body"));
assert_eq!(result, "plain body");
}
#[test]
fn test_resolve_pst_body_cleans_html_when_plain_absent_issue_152() {
let result = resolve_pst_body(None, Some("<p>Hello <b>World</b></p>"), Some("rtf body"));
assert_eq!(result, "Hello World");
}
#[test]
fn test_resolve_pst_body_falls_back_to_rtf_when_only_rtf_present_issue_152() {
let result = resolve_pst_body(None, None, Some("rtf-derived plain text"));
assert_eq!(result, "rtf-derived plain text");
}
#[test]
fn test_resolve_pst_body_empty_when_all_absent_issue_152() {
let result = resolve_pst_body(None, None, None);
assert_eq!(result, "");
}
#[test]
fn test_resolve_pst_body_treats_empty_strings_as_absent_issue_152() {
let result = resolve_pst_body(Some(""), Some(""), Some("rtf fallback"));
assert_eq!(result, "rtf fallback");
}
#[test]
fn test_decompress_and_strip_rtf_via_shared_email_helpers_issue_152() {
let rtf_plain = b"{\\rtf1 Hello RTF World\\par}";
let comp_size = (rtf_plain.len() + 12) as u32;
let mut data = Vec::new();
data.extend_from_slice(&comp_size.to_le_bytes());
data.extend_from_slice(&(rtf_plain.len() as u32).to_le_bytes());
data.extend_from_slice(&0x414c_454du32.to_le_bytes()); data.extend_from_slice(&0u32.to_le_bytes()); data.extend_from_slice(rtf_plain);
let decompressed = super::super::email::decompress_rtf_compressed(&data).expect("should decompress");
let plain = super::super::email::strip_rtf_to_plain_text(&decompressed);
assert_eq!(plain, "Hello RTF World");
}
#[test]
fn test_entry_id_hex_format_issue_764() {
let record_key_bytes: [u8; 16] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
];
let record_key = StoreRecordKey::new(record_key_bytes);
let node_id = NodeId::from(0x04 | (1u32 << 5));
let entry_id = EntryId::new(record_key, node_id);
let node_id_u32 = u32::from(entry_id.node_id());
let node_id_le = node_id_u32.to_le_bytes();
let expected: String = std::iter::repeat_n(0u8, 4)
.chain(record_key_bytes.iter().copied())
.chain(node_id_le.iter().copied())
.map(|b| format!("{b:02X}"))
.collect();
assert_eq!(expected.len(), 48, "MAPI EntryID must be 48 hex chars");
assert!(expected.starts_with("00000000"), "EntryID must start with 00000000");
assert!(!expected.contains("EntryId"), "must not be Debug representation");
assert!(!expected.contains("record_key"), "must not be Debug representation");
assert!(!expected.contains('{'), "must not be Debug representation");
}
#[test]
fn test_filetime_known_epoch() {
let filetime: i64 = 116_444_736_000_000_000;
let result = windows_filetime_to_string(filetime);
assert_eq!(result, "1970-01-01T00:00:00Z");
}
#[test]
fn test_filetime_known_date() {
let filetime: i64 = 133_549_776_000_000_000;
let result = windows_filetime_to_string(filetime);
assert_eq!(result, "2024-03-15T12:00:00Z");
}
#[test]
fn test_filetime_before_unix_epoch_is_invalid() {
let filetime: i64 = 116_444_735_999_999_999;
let result = windows_filetime_to_string(filetime);
assert!(result.starts_with("(invalid timestamp:"));
}
#[test]
fn test_filetime_zero_is_invalid() {
let result = windows_filetime_to_string(0);
assert!(result.starts_with("(invalid timestamp:"));
}
#[test]
fn test_prop_value_integer32_returns_none() {
let val = PropertyValue::Integer32(42);
assert_eq!(prop_value_to_string(&val), None);
}
#[test]
fn test_prop_value_boolean_returns_none() {
let val = PropertyValue::Boolean(true);
assert_eq!(prop_value_to_string(&val), None);
}
#[test]
fn test_prop_value_time_returns_none() {
let val = PropertyValue::Time(133_549_776_000_000_000);
assert_eq!(prop_value_to_string(&val), None);
}
struct FakeStoreWithoutIpmSubtree {
properties: StoreProperties,
}
impl Store for FakeStoreWithoutIpmSubtree {
fn properties(&self) -> &StoreProperties {
&self.properties
}
fn root_hierarchy_table(&self) -> io::Result<Rc<dyn outlook_pst::ltp::table_context::TableContext>> {
Err(io::Error::other("not implemented in test fake"))
}
fn unique_value(&self) -> u32 {
0
}
fn open_folder(&self, _entry_id: &EntryId) -> io::Result<Rc<dyn outlook_pst::messaging::folder::Folder>> {
Err(io::Error::other("not implemented in test fake"))
}
fn open_message(
&self,
_entry_id: &EntryId,
_prop_ids: Option<&[u16]>,
) -> io::Result<Rc<dyn outlook_pst::messaging::message::Message>> {
Err(io::Error::other("not implemented in test fake"))
}
fn named_property_map(&self) -> io::Result<Rc<dyn outlook_pst::messaging::named_prop::NamedPropertyMap>> {
Err(io::Error::other("not implemented in test fake"))
}
fn search_update_queue(&self) -> io::Result<Rc<dyn outlook_pst::messaging::search::SearchUpdateQueue>> {
Err(io::Error::other("not implemented in test fake"))
}
}
#[test]
fn test_extract_from_store_warns_when_ipm_subtree_missing_issue_162() {
let store = FakeStoreWithoutIpmSubtree {
properties: StoreProperties::default(),
};
let (messages, warnings) = extract_from_store(&store);
assert!(
messages.is_empty(),
"no messages should be extracted when the IPM sub-tree cannot be located"
);
assert_eq!(warnings.len(), 1, "exactly one warning should be emitted");
assert_eq!(warnings[0].source.as_ref(), "pst_extraction");
assert!(
warnings[0]
.message
.contains("Failed to locate IPM (mail) sub-tree in PST store"),
"unexpected warning message: {}",
warnings[0].message
);
}
#[test]
fn test_discover_non_ipm_top_level_folders_warns_when_entry_id_creation_fails() {
let store = FakeStoreWithoutIpmSubtree {
properties: StoreProperties::default(),
};
let (seeds, warnings) = discover_non_ipm_top_level_folders(&store, 0);
assert!(
seeds.is_empty(),
"no seeds should be produced when the root entry ID cannot be built"
);
assert_eq!(warnings.len(), 1, "exactly one warning should be emitted");
assert_eq!(warnings[0].source.as_ref(), "pst_extraction");
assert!(
warnings[0]
.message
.contains("Failed to build entry ID for PST root folder"),
"unexpected warning message: {}",
warnings[0].message
);
}
fn empty_pst_fixture_path() -> std::path::PathBuf {
let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates/xberg should have a parent directory")
.parent()
.expect("crates/xberg should have a workspace root two levels up")
.to_path_buf();
workspace_root.join("test_documents/email/empty.pst")
}
struct StoreWithFailingOpenFolder {
inner: Rc<dyn Store>,
}
impl Store for StoreWithFailingOpenFolder {
fn properties(&self) -> &StoreProperties {
self.inner.properties()
}
fn root_hierarchy_table(&self) -> io::Result<Rc<dyn outlook_pst::ltp::table_context::TableContext>> {
self.inner.root_hierarchy_table()
}
fn unique_value(&self) -> u32 {
self.inner.unique_value()
}
fn open_folder(&self, _entry_id: &EntryId) -> io::Result<Rc<dyn Folder>> {
Err(io::Error::other("simulated open_folder failure for test"))
}
fn open_message(
&self,
entry_id: &EntryId,
prop_ids: Option<&[u16]>,
) -> io::Result<Rc<dyn outlook_pst::messaging::message::Message>> {
self.inner.open_message(entry_id, prop_ids)
}
fn named_property_map(&self) -> io::Result<Rc<dyn outlook_pst::messaging::named_prop::NamedPropertyMap>> {
self.inner.named_property_map()
}
fn search_update_queue(&self) -> io::Result<Rc<dyn outlook_pst::messaging::search::SearchUpdateQueue>> {
self.inner.search_update_queue()
}
}
#[test]
fn test_discover_non_ipm_top_level_folders_warns_when_root_folder_cannot_be_opened() {
let fixture = empty_pst_fixture_path();
assert!(fixture.exists(), "PST test fixture not found: {fixture:?}");
let real_store = outlook_pst::open_store(&fixture).expect("should open empty.pst fixture");
let store = StoreWithFailingOpenFolder { inner: real_store };
let (seeds, warnings) = discover_non_ipm_top_level_folders(&store, 0);
assert!(
seeds.is_empty(),
"no seeds should be produced when the root folder cannot be opened"
);
assert_eq!(warnings.len(), 1, "exactly one warning should be emitted");
assert_eq!(warnings[0].source.as_ref(), "pst_extraction");
assert!(
warnings[0].message.contains("Failed to open PST root folder"),
"unexpected warning message: {}",
warnings[0].message
);
}
struct FolderWithNoHierarchyTable;
impl Folder for FolderWithNoHierarchyTable {
fn store(&self) -> Rc<dyn Store> {
unreachable!("discover_non_ipm_top_level_folders never calls Folder::store on the root")
}
fn properties(&self) -> &FolderProperties {
unreachable!("discover_non_ipm_top_level_folders never calls Folder::properties on the root")
}
fn hierarchy_table(&self) -> Option<&Rc<dyn outlook_pst::ltp::table_context::TableContext>> {
None
}
fn contents_table(&self) -> Option<&Rc<dyn outlook_pst::ltp::table_context::TableContext>> {
None
}
fn associated_table(&self) -> Option<&Rc<dyn outlook_pst::ltp::table_context::TableContext>> {
None
}
}
struct StoreWithNoHierarchyTableRoot {
inner: Rc<dyn Store>,
}
impl Store for StoreWithNoHierarchyTableRoot {
fn properties(&self) -> &StoreProperties {
self.inner.properties()
}
fn root_hierarchy_table(&self) -> io::Result<Rc<dyn outlook_pst::ltp::table_context::TableContext>> {
self.inner.root_hierarchy_table()
}
fn unique_value(&self) -> u32 {
self.inner.unique_value()
}
fn open_folder(&self, _entry_id: &EntryId) -> io::Result<Rc<dyn Folder>> {
Ok(Rc::new(FolderWithNoHierarchyTable))
}
fn open_message(
&self,
entry_id: &EntryId,
prop_ids: Option<&[u16]>,
) -> io::Result<Rc<dyn outlook_pst::messaging::message::Message>> {
self.inner.open_message(entry_id, prop_ids)
}
fn named_property_map(&self) -> io::Result<Rc<dyn outlook_pst::messaging::named_prop::NamedPropertyMap>> {
self.inner.named_property_map()
}
fn search_update_queue(&self) -> io::Result<Rc<dyn outlook_pst::messaging::search::SearchUpdateQueue>> {
self.inner.search_update_queue()
}
}
#[test]
fn test_discover_non_ipm_top_level_folders_warns_when_hierarchy_table_is_none() {
let fixture = empty_pst_fixture_path();
assert!(fixture.exists(), "PST test fixture not found: {fixture:?}");
let real_store = outlook_pst::open_store(&fixture).expect("should open empty.pst fixture");
let store = StoreWithNoHierarchyTableRoot { inner: real_store };
let (seeds, warnings) = discover_non_ipm_top_level_folders(&store, 0);
assert!(
seeds.is_empty(),
"no seeds should be produced when the root has no hierarchy table"
);
assert_eq!(warnings.len(), 1, "exactly one warning should be emitted");
assert_eq!(warnings[0].source.as_ref(), "pst_extraction");
assert_eq!(
warnings[0].message.as_ref(),
"PST root folder has no hierarchy table; cannot enumerate non-IPM top-level folders"
);
}
#[test]
fn test_discover_non_ipm_top_level_folders_against_real_empty_pst_fixture_issue_162c() {
let fixture = empty_pst_fixture_path();
assert!(fixture.exists(), "PST test fixture not found: {fixture:?}");
let store = outlook_pst::open_store(&fixture).expect("should open empty.pst fixture");
let ipm_entry = store
.properties()
.ipm_sub_tree_entry_id()
.expect("empty.pst fixture should have a locatable IPM sub-tree");
let ipm_node_id = u32::from(ipm_entry.node_id());
let (seeds, warnings) = discover_non_ipm_top_level_folders(store.as_ref(), ipm_node_id);
assert_eq!(
warnings.len(),
0,
"well-formed empty.pst fixture should not produce warnings, got: {warnings:?}"
);
let mut names: Vec<&str> = seeds.iter().map(|(_, _, name)| name.as_str()).collect();
names.sort_unstable();
assert_eq!(
names,
vec!["IPM_COMMON_VIEWS", "SPAM Search Folder 2", "Search Root"],
"unexpected non-IPM top-level folder set discovered in empty.pst"
);
assert_eq!(seeds.len(), 3, "expected exactly three non-IPM top-level folders");
assert!(
seeds.iter().all(|(_, depth, _)| *depth == 0),
"all non-IPM top-level folders must be reported at depth 0, got: {:?}",
seeds.iter().map(|(_, depth, name)| (depth, name)).collect::<Vec<_>>()
);
}
#[test]
fn test_non_ipm_top_level_ids_filters_out_ipm_node_only() {
let top_level_ids = [10, 20, 30, 40];
assert_eq!(
non_ipm_top_level_ids(&top_level_ids, Some(20)),
vec![10, 30, 40],
"the id matching ipm_node_id must be removed and no others"
);
}
#[test]
fn test_non_ipm_top_level_ids_returns_all_ids_when_ipm_node_id_is_none() {
let top_level_ids = [1, 2, 3];
assert_eq!(non_ipm_top_level_ids(&top_level_ids, None), vec![1, 2, 3]);
}
#[test]
fn test_non_ipm_top_level_ids_returns_all_ids_when_ipm_node_id_not_present() {
let top_level_ids = [5, 6, 7];
assert_eq!(non_ipm_top_level_ids(&top_level_ids, Some(999)), vec![5, 6, 7]);
}
#[test]
fn test_non_ipm_top_level_ids_empty_input_returns_empty() {
let top_level_ids: [u32; 0] = [];
assert_eq!(non_ipm_top_level_ids(&top_level_ids, Some(1)), Vec::<u32>::new());
}
#[test]
fn test_non_ipm_top_level_ids_filters_all_duplicate_ipm_occurrences() {
let top_level_ids = [7, 7, 8, 7];
assert_eq!(non_ipm_top_level_ids(&top_level_ids, Some(7)), vec![8]);
}
#[test]
fn test_search_folder_contents_table_is_none_no_message_aliasing_issue_162() {
let fixture = empty_pst_fixture_path();
assert!(fixture.exists(), "PST test fixture not found: {fixture:?}");
let store = outlook_pst::open_store(&fixture).expect("should open empty.pst fixture");
let ipm_entry = store
.properties()
.ipm_sub_tree_entry_id()
.expect("empty.pst fixture should have a locatable IPM sub-tree");
let ipm_node_id = u32::from(ipm_entry.node_id());
let (seeds, warnings) = discover_non_ipm_top_level_folders(store.as_ref(), ipm_node_id);
assert_eq!(warnings.len(), 0, "unexpected warnings: {warnings:?}");
let (search_folder, _, _) = seeds
.iter()
.find(|(_, _, name)| name == "SPAM Search Folder 2")
.expect("empty.pst fixture should contain the 'SPAM Search Folder 2' search folder");
assert!(
search_folder.contents_table().is_none(),
"outlook-pst's Folder::contents_table() unexpectedly returned Some(_) for a search \
folder -- this crate now exposes real search-folder contents, so walk_folder_tree's \
'no aliasing risk' analysis is stale and de-duplication must be added"
);
}
}