Files
PST_Contents_to_TXT/Extract.py
T

375 lines
14 KiB
Python
Raw Normal View History

2026-09-13 19:53:09 +01:00
"""
PST Email Extractor
====================
Extracts email metadata from Outlook PST files into CSV format.
Supports batch processing of multiple PST files in a directory.
Dependencies:
pip install tqdm libpff-python
(see README.md for platform-specific libpff/pypff installation)
Usage:
python Extract.py [directory]
If no directory is given, the script's own directory is used.
"""
import pypff
import csv
import sys
import os
import glob
from datetime import datetime, timezone
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import lru_cache
from tqdm import tqdm
# ─────────────────────────── helpers ────────────────────────────
def _safe_str(value, max_len: int = 0) -> str:
"""Convert a value to a clean string, optionally truncating it."""
try:
result = "" if value is None else str(value)
except Exception:
return ""
return result[:max_len] if max_len else result
def get_folder_path(folder) -> str:
"""
Walk up the parent chain to reconstruct the full folder path.
Capped at 50 levels to prevent runaway recursion on corrupt files.
"""
parts: list[str] = []
current = folder
for _ in range(50):
if current is None:
break
try:
name = getattr(current, "name", None)
parts.insert(0, str(name) if name else "Unknown_Folder")
current = getattr(current, "parent", None)
except Exception:
break
clean = [p for p in parts if p]
return "\\".join(clean) if clean else "Root"
def _format_dt(dt_obj) -> str:
"""Return an ISO-8601 string for a datetime-like object, UTC-aware."""
if dt_obj is None:
return ""
try:
# pypff delivery/submit times are naive UTC make them explicit
if dt_obj.tzinfo is None:
dt_obj = dt_obj.replace(tzinfo=timezone.utc)
return dt_obj.strftime("%Y-%m-%d %H:%M:%S UTC")
except Exception:
return ""
# ──────────────────────── email extraction ──────────────────────
def extract_email_info(message, folder_path: str) -> dict:
"""
Pull all useful metadata from a pypff message object.
Every field is individually guarded so one bad attribute never
kills an entire message.
"""
# Subject ──────────────────────────────────────────────────────
subject = _safe_str(getattr(message, "subject", ""), max_len=500)
# Sender ───────────────────────────────────────────────────────
try:
sender_name = _safe_str(getattr(message, "sender_name", ""))
sender_email = _safe_str(getattr(message, "sender_email_address", ""))
if sender_name and sender_email:
sender = f"{sender_name} <{sender_email}>"
else:
sender = sender_name or sender_email or "Unknown Sender"
except Exception:
sender = "Unknown Sender"
sender = sender[:200]
# Recipients ───────────────────────────────────────────────────
to_field = ""
try:
count = getattr(message, "recipients_count", 0) or 0
recipients: list[str] = []
for i in range(min(count, 100)):
try:
r = message.recipients[i]
addr = _safe_str(getattr(r, "email_address", ""))
name = _safe_str(getattr(r, "name", ""))
recipients.append(addr or name)
except Exception:
continue
to_field = "; ".join(filter(None, recipients))[:1000]
except Exception:
to_field = ""
# Date ─────────────────────────────────────────────────────────
date_str = (
_format_dt(getattr(message, "delivery_time", None))
or _format_dt(getattr(message, "client_submit_time", None))
)
# Size (bytes) ─────────────────────────────────────────────────
size = 0
for attr in ("plain_text_body", "html_body", "transport_headers", "subject"):
try:
content = getattr(message, attr, None)
if content:
# pypff often returns bytes; len() works on both str and bytes
size += len(content)
except Exception:
continue
# Attachments count ────────────────────────────────────────────
attachments = 0
try:
attachments = getattr(message, "number_of_attachments", 0) or 0
except Exception:
pass
# Message class (e.g. IPM.Note, IPM.Appointment …) ────────────
msg_class = _safe_str(getattr(message, "message_class", ""), max_len=100)
return {
"Folder": str(folder_path)[:200],
"Subject": subject,
"From": sender,
"To": to_field,
"Date": date_str,
"Size": str(size),
"Attachments": str(attachments),
"MessageClass": msg_class,
}
# ────────────────────────── counting ────────────────────────────
def count_total_messages(folder) -> int:
"""Recursively count every message in the folder tree (for the progress bar)."""
total = 0
try:
total += getattr(folder, "number_of_sub_messages", 0) or 0
for i in range(getattr(folder, "number_of_sub_folders", 0) or 0):
try:
sub = folder.get_sub_folder(i)
if sub:
total += count_total_messages(sub)
except Exception:
continue
except Exception:
pass
return total
# ───────────────────────── folder walk ──────────────────────────
def process_folder(folder, csv_writer, pbar, counters: dict):
"""
Recursively walk all folders and write one CSV row per message.
Uses an iterative approach for subfolders to avoid Python recursion limits
on deeply nested PST files.
"""
folder_stack = [folder]
while folder_stack:
current = folder_stack.pop()
try:
folder_path = get_folder_path(current)
except Exception:
folder_path = "Unknown"
# ── messages in this folder ──
n_msgs = getattr(current, "number_of_sub_messages", 0) or 0
for i in range(n_msgs):
try:
msg = current.get_sub_message(i)
if msg is None:
continue
info = extract_email_info(msg, folder_path)
csv_writer.writerow([
info["Folder"],
info["Subject"],
info["From"],
info["To"],
info["Date"],
info["Size"],
info["Attachments"],
info["MessageClass"],
])
counters["ok"] += 1
except Exception:
counters["err"] += 1
finally:
pbar.update(1)
# ── queue subfolders ──
n_subs = getattr(current, "number_of_sub_folders", 0) or 0
for i in range(n_subs):
try:
sub = current.get_sub_folder(i)
if sub:
folder_stack.append(sub)
except Exception:
continue
# ───────────────────────── PST export ───────────────────────────
FIELDNAMES = ["Folder", "Subject", "From", "To", "Date", "Size", "Attachments", "MessageClass"]
def export_pst_to_csv(pst_file_path: str, output_file_path: str) -> bool:
"""
Open a single PST file, walk its entire folder tree, and write metadata
for every message to a UTF-8 CSV file.
Returns True on success, False on failure.
"""
if not os.path.exists(pst_file_path):
print(f" ❌ PST file not found: {pst_file_path}")
return False
# ── open PST ──────────────────────────────────────────────────
try:
pst = pypff.file()
pst.open(pst_file_path)
except Exception as exc:
print(f" ❌ Cannot open PST: {exc}")
return False
try:
root = pst.get_root_folder()
if root is None:
print(" ❌ Cannot access root folder")
return False
base = os.path.basename(pst_file_path)
print(f" 📊 Counting messages in {base} …")
total = count_total_messages(root)
print(f" ✓ {total:,} messages found")
counters = {"ok": 0, "err": 0}
with open(output_file_path, "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(FIELDNAMES)
bar_fmt = "{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]"
with tqdm(total=total, desc=f" Processing {base}", unit="email",
bar_format=bar_fmt) as pbar:
process_folder(root, writer, pbar, counters)
print(f" ✓ Written {counters['ok']:,} rows | Errors skipped: {counters['err']:,}")
return True
except Exception as exc:
print(f" ❌ Critical error: {exc}")
return False
finally:
try:
pst.close()
except Exception:
pass
# ────────────────────── batch processing ────────────────────────
def find_pst_files(directory: str) -> list[str]:
"""Return sorted list of .pst files in *directory* (non-recursive)."""
return sorted(glob.glob(os.path.join(directory, "*.pst")))
def process_all_pst_files(directory: str) -> None:
"""Discover and process every PST file found in *directory*."""
sep = "=" * 80
print(sep)
print("🔍 SEARCHING FOR PST FILES")
print(sep)
pst_files = find_pst_files(directory)
if not pst_files:
print(f"❌ No PST files found in: {directory}")
return
total_files = len(pst_files)
print(f"✓ Found {total_files} PST file(s)")
print()
print("📁 PST Files:")
for idx, f in enumerate(pst_files, 1):
size_mb = os.path.getsize(f) / (1024 ** 2)
print(f" {idx:>3}. {os.path.basename(f)} ({size_mb:.1f} MB)")
print()
print(sep)
print("🚀 PROCESSING")
print(sep)
ok_count = 0
fail_count = 0
for idx, pst_file in enumerate(pst_files, 1):
remaining = total_files - idx
print()
print("─" * 80)
print(f"📊 {idx}/{total_files} | ✓ Done: {ok_count} | ❌ Failed: {fail_count} | ⏳ Remaining: {remaining}")
print(f"🔄 {os.path.basename(pst_file)}")
base_name = os.path.splitext(os.path.basename(pst_file))[0]
output_file = os.path.join(directory, f"{base_name}_email_list.csv")
if export_pst_to_csv(pst_file, output_file):
ok_count += 1
rows = sum(1 for _ in open(output_file, encoding="utf-8")) - 1 # minus header
print(f" ✅ Saved → {os.path.basename(output_file)} ({rows:,} rows)")
else:
fail_count += 1
print(" ❌ Failed")
# ── summary ───────────────────────────────────────────────────
print()
print(sep)
print("📈 FINAL SUMMARY")
print(sep)
print(f" PST files found: {total_files}")
print(f" ✓ Processed: {ok_count}")
print(f" ❌ Failed: {fail_count}")
if total_files:
print(f" Success rate: {ok_count / total_files * 100:.1f}%")
print(sep)
if ok_count == total_files:
print("🎉 All files processed successfully!")
elif ok_count:
print("⚠️ Some files failed check errors above.")
else:
print("❌ No files were processed. Check errors above.")
print(sep)
# ─────────────────────────── entry point ────────────────────────
if __name__ == "__main__":
if len(sys.argv) > 1:
directory = sys.argv[1]
else:
directory = os.path.dirname(os.path.abspath(__file__))
print(f"️ No directory specified using script location: {directory}")
print(f"️ Tip: python {os.path.basename(__file__)} <directory_path>")
print()
if not os.path.isdir(directory):
print(f"❌ Not a valid directory: {directory}")
sys.exit(1)
print(f"📂 Working directory: {directory}")
print("️ Corrupt or unsupported messages are skipped automatically.")
print()
process_all_pst_files(directory)