Files
mailcat/analyse_mailbox.py
2026-07-20 20:49:36 +02:00

400 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Fase 1: Analyseer lokale mailbox-kopie en genereer overzichten.
Output: reports/mailinglijsten.md, facturen_mailbox.md, facturen_links.md, prullenbak.md
"""
import email
import email.header
import email.utils
import re
import sys
from pathlib import Path
from collections import defaultdict
from datetime import datetime, timezone
MAILBOX_DIR = Path(__file__).parent / "mailbox"
REPORTS_DIR = Path(__file__).parent / "reports"
INVOICE_SUBJECT_KW = [
"factuur", "invoice", "rekening", "nota", "kwitantie", "receipt",
"betaalverzoek", "betalingsverzoek", "betaling ontvangen",
"payment received", "order confirmation", "orderbevestiging",
"your order", "je bestelling", "proforma",
]
INVOICE_ATTACH_KW = [
"factuur", "invoice", "rekening", "nota", "receipt", "bill",
]
INVOICE_LINK_KW = [
"bekijk je factuur", "download je factuur", "download your invoice",
"view your invoice", "your invoice is ready", "je factuur staat klaar",
"factuur is beschikbaar", "invoice is available", "factuur online",
"invoice online", "mijn facturen", "my invoices", "portaal", "portal",
"mijn account", "my account", "log in to view", "inloggen om te bekijken",
]
TRASH_SENDER_RE = re.compile(
r"(noreply|no-reply|donotreply|do-not-reply|notifications?|newsletter|"
r"nieuwsbrief|alerts?|updates?|mailer-daemon|postmaster|bounce|"
r"auto-?reply|automatisch)@",
re.I,
)
TRASH_SUBJECT_RE = re.compile(
r"(out of office|automatisch antwoord|afwezig|buiten kantoor|"
r"delivery (status notification|failed|failure)|undeliverable|"
r"mail delivery|mailer-daemon|bounced|returned mail)",
re.I,
)
# ── helpers ─────────────────────────────────────────────────────────────────
def decode_hdr(value: str | None) -> str:
if not value:
return ""
try:
parts = email.header.decode_header(value)
out = []
for raw, cs in parts:
if isinstance(raw, bytes):
out.append(raw.decode(cs or "utf-8", errors="replace"))
else:
out.append(str(raw))
return " ".join(out).strip()
except Exception:
return str(value or "")
def parse_dt(date_str: str | None) -> datetime | None:
if not date_str:
return None
try:
return email.utils.parsedate_to_datetime(date_str)
except Exception:
return None
def sender_domain(from_addr: str) -> str:
m = re.search(r"@([\w.\-]+)", from_addr)
return m.group(1).lower() if m else "onbekend"
def get_body(msg) -> str:
parts = []
try:
for part in (msg.walk() if msg.is_multipart() else [msg]):
if part.get_content_type() in ("text/plain", "text/html"):
cs = part.get_content_charset() or "utf-8"
try:
parts.append(part.get_payload(decode=True).decode(cs, errors="replace"))
except Exception:
pass
except Exception:
pass
return "\n".join(parts)[:4000]
def get_attachments(msg) -> list[dict]:
result = []
try:
for part in msg.walk():
disp = (part.get("Content-Disposition") or "").lower()
ct = part.get_content_type() or ""
fn = decode_hdr(part.get_filename() or "")
if "attachment" in disp or (fn and ct == "application/pdf"):
result.append({"filename": fn, "type": ct})
except Exception:
pass
return result
def extract_urls(text: str) -> list[str]:
return list(dict.fromkeys(re.findall(r"https?://[^\s<>\"')]+", text)))
def url_domain(url: str) -> str:
m = re.match(r"https?://([^/]+)", url)
return m.group(1).lower() if m else ""
# ── main analysis ────────────────────────────────────────────────────────────
def analyze():
REPORTS_DIR.mkdir(exist_ok=True)
eml_files = list(MAILBOX_DIR.rglob("*.eml"))
total = len(eml_files)
print(f"Analyseren van {total} e-mails...\n")
# mailinglijsten: key → {count, list_id, unsubscribe, senders, dates, folder}
ml: dict[str, dict] = defaultdict(lambda: {
"list_id": "", "unsubscribe": [], "senders": set(),
"count": 0, "dates": [], "folder": "",
})
invoices_in_mb: list[dict] = []
invoice_links: list[dict] = []
trash: list[dict] = []
now_utc = datetime.now(timezone.utc)
for i, path in enumerate(eml_files, 1):
if i % 1000 == 0:
print(f" {i}/{total}...", flush=True)
try:
with open(path, "rb") as f:
msg = email.message_from_bytes(f.read())
except Exception:
continue
folder = path.parent.name
from_raw = decode_hdr(msg.get("From", ""))
subject = decode_hdr(msg.get("Subject", ""))
date = parse_dt(msg.get("Date"))
list_id = (msg.get("List-ID") or "").strip()
list_us = (msg.get("List-Unsubscribe") or "").strip()
prec = (msg.get("Precedence") or "").lower()
from_lo = from_raw.lower()
subj_lo = subject.lower()
domain = sender_domain(from_raw)
attachments = get_attachments(msg)
body = get_body(msg)
body_lo = body.lower()
# ── mailinglijsten ────────────────────────────────────────────────
ml_key = None
if list_id:
ml_key = re.sub(r"[<>]", "", list_id).strip()
elif "mailinglists" in folder.lower() or "mailinglist" in folder.lower():
ml_key = folder # use the subfolder name as key
if ml_key:
entry = ml[ml_key]
entry["list_id"] = list_id or ml_key
entry["count"] += 1
entry["folder"] = folder
entry["senders"].add(from_raw)
if date:
entry["dates"].append(date)
if list_us and list_us not in entry["unsubscribe"]:
entry["unsubscribe"].append(list_us)
# ── facturen in mailbox ───────────────────────────────────────────
invoice_reasons: list[str] = []
for att in attachments:
fn_lo = att["filename"].lower()
if att["type"] == "application/pdf" and any(kw in fn_lo for kw in INVOICE_ATTACH_KW):
invoice_reasons.append(f"PDF: {att['filename']}")
if any(kw in subj_lo for kw in INVOICE_SUBJECT_KW):
invoice_reasons.append("factuurtermen in onderwerp")
# always include what's already in the Facturen folders
if "facturen" in folder.lower():
invoice_reasons.append(f"staat in map {folder}")
if invoice_reasons:
invoices_in_mb.append({
"from": from_raw,
"domain": domain,
"subject": subject,
"date": date,
"folder": folder,
"reasons": invoice_reasons,
"attachments": [a["filename"] for a in attachments if a["type"] == "application/pdf"],
})
# ── factuurlinks ──────────────────────────────────────────────────
has_link_kw = any(kw in body_lo for kw in INVOICE_LINK_KW)
has_no_pdf = not any(a["type"] == "application/pdf" for a in attachments)
if has_link_kw and has_no_pdf:
urls = extract_urls(body)
if urls:
invoice_links.append({
"from": from_raw,
"domain": domain,
"subject": subject,
"date": date,
"folder": folder,
"urls": urls[:5],
"url_domains": list(dict.fromkeys(url_domain(u) for u in urls[:5])),
})
# ── prullenbak ────────────────────────────────────────────────────
trash_reasons: list[str] = []
if TRASH_SENDER_RE.search(from_lo):
trash_reasons.append("automatisch afzenderadres")
if TRASH_SUBJECT_RE.search(subj_lo):
trash_reasons.append("automatisch/fout-onderwerp")
if prec in ("bulk", "junk"):
trash_reasons.append(f"Precedence: {prec}")
# Old bulk mail sitting in INBOX
if folder == "INBOX" and prec in ("bulk", "list", "junk") and date:
age = (now_utc - date.astimezone(timezone.utc)).days
if age > 180:
trash_reasons.append(f"bulk in INBOX, {age} dagen oud")
if trash_reasons:
trash.append({
"from": from_raw,
"domain": domain,
"subject": subject,
"date": date,
"folder": folder,
"reasons": trash_reasons,
"path": str(path),
})
print(f" {total}/{total} klaar.\n")
write_mailinglist_report(ml)
write_invoice_report(invoices_in_mb)
write_invoice_links_report(invoice_links)
write_trash_report(trash)
print(f"\nRapporten opgeslagen in: {REPORTS_DIR.resolve()}")
# ── report writers ───────────────────────────────────────────────────────────
def fmt_date(d: datetime | None) -> str:
return d.strftime("%Y-%m-%d") if d else "?"
def write_mailinglist_report(ml: dict):
lines = [
"# Mailinglijsten\n",
f"Totaal gevonden: **{len(ml)}** lijsten.\n",
"_Advies: > 30 mails = hoog volume, overweeg afmelden._\n",
]
for key, entry in sorted(ml.items(), key=lambda x: -x[1]["count"]):
dates = sorted(entry["dates"])
oldest = fmt_date(dates[0]) if dates else "?"
newest = fmt_date(dates[-1]) if dates else "?"
senders = sorted(entry["senders"])[:2]
unsub = entry["unsubscribe"]
if entry["count"] > 30:
advice = "⚠️ Hoog volume overweeg afmelden"
elif entry["count"] > 10:
advice = "Gemiddeld volume beoordeel zelf"
else:
advice = "Laag volume waarschijnlijk houden"
can_unsub = "Ja" if unsub else "Nee (handmatig opzoeken)"
lines += [
f"## {key}",
f"- **Mails:** {entry['count']} | **Periode:** {oldest} {newest}",
f"- **Map:** {entry['folder']}",
f"- **Afzender:** {', '.join(senders)}",
f"- **Afmelden mogelijk:** {can_unsub}",
]
if unsub:
lines.append(f"- **Afmeldlink:** `{unsub[0][:120]}`")
lines += [f"- **Advies:** {advice}", ""]
path = REPORTS_DIR / "mailinglijsten.md"
path.write_text("\n".join(lines), encoding="utf-8")
print(f"✓ mailinglijsten.md ({len(ml)} lijsten)")
def write_invoice_report(invoices: list[dict]):
lines = [
"# Facturen in mailbox\n",
f"Totaal: **{len(invoices)}** factuurmails gevonden.\n",
]
by_domain: dict[str, list] = defaultdict(list)
for inv in invoices:
by_domain[inv["domain"]].append(inv)
for domain, items in sorted(by_domain.items(), key=lambda x: -len(x[1])):
items_sorted = sorted(items, key=lambda x: x["date"] or datetime.min.replace(tzinfo=timezone.utc))
lines.append(f"## {domain} ({len(items)} mails)")
for item in items_sorted[:25]:
atts = f" → {', '.join(item['attachments'])}" if item["attachments"] else ""
lines.append(f"- {fmt_date(item['date'])} | {item['subject'][:80]}{atts}")
if len(items) > 25:
lines.append(f" _...en {len(items) - 25} meer_")
lines.append("")
(REPORTS_DIR / "facturen_mailbox.md").write_text("\n".join(lines), encoding="utf-8")
print(f"✓ facturen_mailbox.md ({len(invoices)} facturen)")
def write_invoice_links_report(invoice_links: list[dict]):
lines = [
"# Factuurlinks verwijzingen naar portalen\n",
f"Totaal: **{len(invoice_links)}** mails met factuurverwijzingen (zonder PDF-bijlage).\n",
"_Dit zijn mails die zeggen 'bekijk je factuur online' de factuur staat op een website._\n",
]
by_url_domain: dict[str, list] = defaultdict(list)
for item in invoice_links:
for ud in (item["url_domains"] or ["onbekend"]):
by_url_domain[ud].append(item)
for ud, items in sorted(by_url_domain.items(), key=lambda x: -len(x[1])):
unique_senders = sorted({i["domain"] for i in items})
lines.append(f"## {ud} ({len(items)} mails)")
lines.append(f" _Afzenders: {', '.join(unique_senders[:5])}_")
for item in sorted(items, key=lambda x: x["date"] or datetime.min.replace(tzinfo=timezone.utc))[:10]:
lines.append(f"- {fmt_date(item['date'])} | {item['subject'][:80]}")
for url in item["urls"][:2]:
lines.append(f" `{url[:120]}`")
if len(items) > 10:
lines.append(f" _...en {len(items) - 10} meer_")
lines.append("")
(REPORTS_DIR / "facturen_links.md").write_text("\n".join(lines), encoding="utf-8")
print(f"✓ facturen_links.md ({len(invoice_links)} mails)")
def write_trash_report(trash: list[dict]):
lines = [
"# Prullenbak-kandidaten\n",
f"Totaal: **{len(trash)}** kandidaten gevonden.\n",
]
by_reason: dict[str, list] = defaultdict(list)
for item in trash:
by_reason[item["reasons"][0]].append(item)
for reason, items in sorted(by_reason.items(), key=lambda x: -len(x[1])):
lines.append(f"## {reason} ({len(items)} mails)")
by_domain: dict[str, list] = defaultdict(list)
for item in items:
by_domain[item["domain"]].append(item)
def _sort_key(x):
d = x["date"]
if d is None:
return datetime.min.replace(tzinfo=timezone.utc)
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
for domain, ditems in sorted(by_domain.items(), key=lambda x: -len(x[1])):
lines.append(f"### {domain} ({len(ditems)})")
for item in sorted(ditems, key=_sort_key)[:10]:
lines.append(f"- {fmt_date(item['date'])} | {item['folder']} | {item['subject'][:70]}")
if len(ditems) > 10:
lines.append(f" _...en {len(ditems) - 10} meer_")
lines.append("")
(REPORTS_DIR / "prullenbak.md").write_text("\n".join(lines), encoding="utf-8")
print(f"✓ prullenbak.md ({len(trash)} kandidaten)")
if __name__ == "__main__":
analyze()