diff --git a/castle.yaml b/castle.yaml index cc99e8a..a9bb766 100644 --- a/castle.yaml +++ b/castle.yaml @@ -155,13 +155,13 @@ components: alias: devbox-connect tool: source: devbox-connect/ - mboxer: + mbox2eml: description: MBOX to EML email converter install: path: - alias: mboxer + alias: mbox2eml tool: - source: mboxer/ + source: tools/document/ android-backup: description: Backup Android device using ADB install: diff --git a/tools/document/pyproject.toml b/tools/document/pyproject.toml index 5a7449a..8b0b758 100644 --- a/tools/document/pyproject.toml +++ b/tools/document/pyproject.toml @@ -10,6 +10,7 @@ docx2md = "document.docx2md:main" pdf2md = "document.pdf2md:main" html2text = "document.html2text:main" md2pdf = "document.md2pdf:main" +mbox2eml = "document.mbox2eml:main" [build-system] requires = ["hatchling"] diff --git a/tools/document/src/document/mbox2eml.md b/tools/document/src/document/mbox2eml.md new file mode 100644 index 0000000..8f760e3 --- /dev/null +++ b/tools/document/src/document/mbox2eml.md @@ -0,0 +1,25 @@ +# mbox2eml + +Convert MBOX mailbox files to individual .eml files with clean, descriptive +filenames based on date, sender, and recipient. + +## Usage + +```bash +mbox2eml mailbox.mbox # Export to eml_output/ +mbox2eml mailbox.mbox -o emails/ # Export to custom directory +mbox2eml mailbox.mbox --max-length 80 # Truncate filenames at 80 chars +``` + +Output filenames look like: + +``` +2025-03-23_15-09-42_from_alice_example_com_to_bob_example_com.eml +``` + +## What can you do with .eml files? + +- Open in email clients (Thunderbird, Outlook, etc.) +- Parse and analyze with Python or CLI tools +- Extract metadata, body, or attachments +- Archive and search with grep, ripgrep, etc. diff --git a/tools/document/src/document/mbox2eml.py b/tools/document/src/document/mbox2eml.py new file mode 100644 index 0000000..51c51b2 --- /dev/null +++ b/tools/document/src/document/mbox2eml.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +mbox2eml: Convert MBOX mailbox files to individual .eml files + +Reads an .mbox file and exports each message as a separate .eml file +with clean, descriptive filenames based on date, sender, and recipient. + +Usage: + mboxer mailbox.mbox + mboxer mailbox.mbox -o output_dir/ + mboxer mailbox.mbox --max-length 80 + +Options: + -o, --output DIR Output directory (default: eml_output) + --max-length N Max filename length before truncation (default: 100) +""" + +import argparse +import email +import email.header +import email.utils +import mailbox +import os +import re +import sys + + +def sanitize_filename(name: str) -> str: + """Replace non-word characters with underscores.""" + return re.sub(r"[^\w\-_.]", "_", name) + + +def decode_header_field(header_value: str | None) -> str: + """Decode an email header field to a plain string.""" + if header_value is None: + return "" + if isinstance(header_value, email.header.Header): + return str(header_value) + decoded_fragments = email.header.decode_header(header_value) + return "".join( + str(t[0], t[1] or "utf-8") if isinstance(t[0], bytes) else t[0] + for t in decoded_fragments + ) + + +def extract_addresses(header_value: str | None) -> list[str]: + """Extract lowercase email addresses from a header field.""" + decoded = decode_header_field(header_value) + return [ + email.utils.parseaddr(addr)[1].lower() + for addr in decoded.split(",") + if addr.strip() + ] + + +def convert(mbox_path: str, output_dir: str, max_length: int = 100) -> int: + """Convert an mbox file to individual .eml files. Returns count of exported messages.""" + if not os.path.exists(mbox_path): + print(f"Error: file not found: {mbox_path}", file=sys.stderr) + return -1 + + os.makedirs(output_dir, exist_ok=True) + mbox = mailbox.mbox(mbox_path) + count = 0 + + for i, msg in enumerate(mbox): + date_str = msg.get("date") + try: + parsed_date = email.utils.parsedate_to_datetime(date_str) # type: ignore[arg-type] + timestamp = parsed_date.strftime("%Y-%m-%d_%H-%M-%S") + except Exception: + timestamp = f"unknown_date_{i:04}" + + from_addr = email.utils.parseaddr(decode_header_field(msg.get("from")))[1].lower() + to_addrs = extract_addresses(msg.get("to")) + safe_sender = sanitize_filename(from_addr or "unknown") + + if not to_addrs: + safe_recipient = "unknown" + elif len(to_addrs) == 1: + safe_recipient = sanitize_filename(to_addrs[0]) + else: + safe_recipient = sanitize_filename(to_addrs[0]) + "_et_al" + + base_name = f"{timestamp}_from_{safe_sender}_to_{safe_recipient}" + if len(base_name) > max_length: + base_name = base_name[:max_length] + filename = base_name + ".eml" + filepath = os.path.join(output_dir, filename) + + try: + with open(filepath, "wb") as f: + f.write(bytes(msg)) + print(f"[{i + 1:04}] Saved: {filename}", file=sys.stderr) + count += 1 + except Exception as e: + print(f"[{i + 1:04}] Error saving {filename}: {e}", file=sys.stderr) + + return count + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Convert MBOX mailbox files to individual .eml files", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("input", help="Input .mbox file") + parser.add_argument( + "-o", "--output", default="eml_output", + help="Output directory (default: eml_output)", + ) + parser.add_argument( + "--max-length", type=int, default=100, + help="Max filename length (default: 100)", + ) + parser.add_argument("--version", action="version", version="mbox2eml 1.0.0") + args = parser.parse_args() + + count = convert(args.input, args.output, args.max_length) + if count < 0: + return 1 + + print(f"Exported {count} messages to {args.output}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main())