feat: add mbox2eml tool for converting MBOX files to EML format
This commit is contained in:
@@ -155,13 +155,13 @@ components:
|
|||||||
alias: devbox-connect
|
alias: devbox-connect
|
||||||
tool:
|
tool:
|
||||||
source: devbox-connect/
|
source: devbox-connect/
|
||||||
mboxer:
|
mbox2eml:
|
||||||
description: MBOX to EML email converter
|
description: MBOX to EML email converter
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: mboxer
|
alias: mbox2eml
|
||||||
tool:
|
tool:
|
||||||
source: mboxer/
|
source: tools/document/
|
||||||
android-backup:
|
android-backup:
|
||||||
description: Backup Android device using ADB
|
description: Backup Android device using ADB
|
||||||
install:
|
install:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ docx2md = "document.docx2md:main"
|
|||||||
pdf2md = "document.pdf2md:main"
|
pdf2md = "document.pdf2md:main"
|
||||||
html2text = "document.html2text:main"
|
html2text = "document.html2text:main"
|
||||||
md2pdf = "document.md2pdf:main"
|
md2pdf = "document.md2pdf:main"
|
||||||
|
mbox2eml = "document.mbox2eml:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
25
tools/document/src/document/mbox2eml.md
Normal file
25
tools/document/src/document/mbox2eml.md
Normal file
@@ -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.
|
||||||
128
tools/document/src/document/mbox2eml.py
Normal file
128
tools/document/src/document/mbox2eml.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user