v2
This commit is contained in:
+374
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user