357 lines
12 KiB
Python
357 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Interactief script om mailinglijsten te beoordelen.
|
||
h = houden | a = afmelden | s = overslaan | q = stoppen
|
||
|
||
Eerste run: scant mailbox en slaat cache op in mailing_lists_cache.json.
|
||
Volgende runs: laadt direct uit cache.
|
||
Beslissingen worden opgeslagen in decisions.json (herstart-safe).
|
||
"""
|
||
|
||
import email
|
||
import email.header
|
||
import email.utils
|
||
import re
|
||
import sys
|
||
import tty
|
||
import termios
|
||
import json
|
||
from pathlib import Path
|
||
from collections import defaultdict, Counter
|
||
from datetime import timezone
|
||
|
||
MAILBOX_DIR = Path(__file__).parent / "mailbox"
|
||
CACHE_FILE = Path(__file__).parent / "mailing_lists_cache.json"
|
||
DECISIONS_FILE = Path(__file__).parent / "decisions.json"
|
||
|
||
# Domeinen die door meerdere afzenders worden gedeeld → groeperen op naam, niet domein
|
||
MULTITENANT_DOMAINS = {
|
||
"substack.com", "mcsv.net", "list-manage.com",
|
||
"sparkpostmail.com", "eu.sparkpostmail.com",
|
||
"convertkit-mail.com", "convertkit-mail2.com", "convertkit-mail4.com",
|
||
"sendgrid.net", "mailgun.org", "dripemail.com", "dripemail3.com",
|
||
"mailchimpapp.net", "rsgsv.net",
|
||
}
|
||
|
||
MIN_COUNT = 3 # lijsten met minder dan dit aantal mails worden overgeslagen
|
||
|
||
|
||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
def decode_hdr(value) -> 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(s):
|
||
if not s:
|
||
return None
|
||
try:
|
||
return email.utils.parsedate_to_datetime(s)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def to_utc_ts(d) -> float | None:
|
||
if d is None:
|
||
return None
|
||
if d.tzinfo is None:
|
||
d = d.replace(tzinfo=timezone.utc)
|
||
return d.timestamp()
|
||
|
||
|
||
_EMAIL_SUBDOMAINS = re.compile(
|
||
r"^(email|mail|e|m|news|newsletter|noreply|no-reply|lists?|"
|
||
r"mailing|bounce|notifications?|updates?|info|reply|send|smtp|"
|
||
r"mg|sg|em|learn|go|click|edm|cdn|app|get)\.",
|
||
re.I,
|
||
)
|
||
|
||
def sender_domain(from_addr: str) -> str:
|
||
m = re.search(r"@([\w.\-]+)", from_addr)
|
||
if not m:
|
||
return "onbekend"
|
||
domain = m.group(1).lower()
|
||
# Strip bekende e-mail-subdomeinen zodat bijv. email.coursera.org → coursera.org
|
||
parts = domain.split(".")
|
||
while len(parts) > 2 and _EMAIL_SUBDOMAINS.match(parts[0] + "."):
|
||
parts = parts[1:]
|
||
return ".".join(parts)
|
||
|
||
|
||
def display_name(from_addr: str) -> str:
|
||
"""Haal leesbare naam op; val terug op domein."""
|
||
m = re.match(r'^"?([^"<@\n]{3,})"?\s*<', from_addr)
|
||
if m:
|
||
name = m.group(1).strip().strip('"').strip()
|
||
if len(name) > 2:
|
||
return name
|
||
return sender_domain(from_addr)
|
||
|
||
|
||
def normalize_name(name: str) -> str:
|
||
"""Normaliseer naam voor groepering (lowercase, alleen woorden)."""
|
||
return " ".join(re.findall(r"[a-z0-9]+", name.lower()))
|
||
|
||
|
||
def fmt_date(ts: float | None) -> str:
|
||
if ts is None:
|
||
return "?"
|
||
from datetime import datetime
|
||
return datetime.utcfromtimestamp(ts).strftime("%Y-%m-%d")
|
||
|
||
|
||
def getch() -> str:
|
||
fd = sys.stdin.fileno()
|
||
old = termios.tcgetattr(fd)
|
||
try:
|
||
tty.setraw(fd)
|
||
return sys.stdin.read(1)
|
||
finally:
|
||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||
|
||
|
||
# ── cache bouwen ─────────────────────────────────────────────────────────────
|
||
|
||
def build_cache() -> list[dict]:
|
||
"""
|
||
Scan mailbox headers en groepeer op weergavenaam.
|
||
Elke entry in de cache is één logische mailinglijst.
|
||
"""
|
||
eml_files = list(MAILBOX_DIR.rglob("*.eml"))
|
||
total = len(eml_files)
|
||
print(f"Scannen van {total} e-mails (alleen headers)...", flush=True)
|
||
|
||
# Per weergavenaam (genormaliseerd) verzamelen we alle info
|
||
by_name: dict[str, dict] = defaultdict(lambda: {
|
||
"names": [], # alle aangetroffen weergavenamen
|
||
"domains": [], # alle aangetroffen domeinen
|
||
"unsubscribe": [], # List-Unsubscribe waarden
|
||
"count": 0,
|
||
"timestamps": [], # als float (UTC)
|
||
"subjects": [],
|
||
"folders": [],
|
||
})
|
||
|
||
for i, path in enumerate(eml_files, 1):
|
||
if i % 2000 == 0:
|
||
print(f" {i}/{total}...", flush=True)
|
||
|
||
try:
|
||
with open(path, "rb") as f:
|
||
chunk = f.read(8192)
|
||
msg = email.message_from_bytes(chunk)
|
||
except Exception:
|
||
continue
|
||
|
||
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()
|
||
folder = path.parent.name
|
||
|
||
is_ml = bool(
|
||
list_id or list_us
|
||
or prec in ("bulk", "list")
|
||
or "mailinglist" in folder.lower()
|
||
)
|
||
if not is_ml:
|
||
continue
|
||
|
||
name = display_name(from_raw)
|
||
domain = sender_domain(from_raw)
|
||
# Multi-tenant domeinen: groepeer op genormaliseerde naam
|
||
# Gewone domeinen: groepeer op domein (voorkomt splits zoals "LinkedIn" vs "LinkedIn Job Alerts")
|
||
if domain in MULTITENANT_DOMAINS:
|
||
key = normalize_name(name) or domain
|
||
else:
|
||
key = domain
|
||
|
||
e = by_name[key]
|
||
e["names"].append(name)
|
||
e["domains"].append(domain)
|
||
e["count"] += 1
|
||
e["folders"].append(folder)
|
||
ts = to_utc_ts(date)
|
||
if ts:
|
||
e["timestamps"].append(ts)
|
||
if len(e["subjects"]) < 3:
|
||
e["subjects"].append(subject)
|
||
if list_us and list_us not in e["unsubscribe"]:
|
||
e["unsubscribe"].append(list_us)
|
||
|
||
print(f" {total}/{total} klaar.\n")
|
||
|
||
# Omzetten naar gesorteerde lijst met mooie namen
|
||
result = []
|
||
for key, e in by_name.items():
|
||
best_name = Counter(e["names"]).most_common(1)[0][0]
|
||
best_domain = Counter(e["domains"]).most_common(1)[0][0]
|
||
best_folder = Counter(e["folders"]).most_common(1)[0][0]
|
||
ts_sorted = sorted(e["timestamps"])
|
||
|
||
result.append({
|
||
"key": key,
|
||
"name": best_name,
|
||
"domain": best_domain,
|
||
"folder": best_folder,
|
||
"count": e["count"],
|
||
"oldest": ts_sorted[0] if ts_sorted else None,
|
||
"newest": ts_sorted[-1] if ts_sorted else None,
|
||
"subjects": e["subjects"],
|
||
"unsubscribe": e["unsubscribe"],
|
||
"domains": list(set(e["domains"])),
|
||
})
|
||
|
||
result.sort(key=lambda x: -x["count"])
|
||
result = [e for e in result if e["count"] >= MIN_COUNT]
|
||
|
||
CACHE_FILE.write_text(
|
||
json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8"
|
||
)
|
||
print(f"{len(result)} unieke mailinglijsten opgeslagen in cache.\n")
|
||
return result
|
||
|
||
|
||
def load_cache() -> list[dict]:
|
||
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
||
|
||
|
||
# ── beslissingen ─────────────────────────────────────────────────────────────
|
||
|
||
def load_decisions() -> dict:
|
||
if not DECISIONS_FILE.exists():
|
||
return {}
|
||
try:
|
||
data = json.loads(DECISIONS_FILE.read_text(encoding="utf-8"))
|
||
return {k: (v["keuze"] if isinstance(v, dict) else v) for k, v in data.items()}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def save_decisions(decisions: dict, lists: list[dict]):
|
||
by_key = {e["key"]: e for e in lists}
|
||
output = {}
|
||
for key, keuze in decisions.items():
|
||
e = by_key.get(key, {})
|
||
output[key] = {
|
||
"keuze": keuze,
|
||
"naam": e.get("name", key),
|
||
"count": e.get("count", 0),
|
||
"domains": e.get("domains", []),
|
||
"unsubscribe": e.get("unsubscribe", []),
|
||
}
|
||
DECISIONS_FILE.write_text(
|
||
json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8"
|
||
)
|
||
|
||
|
||
# ── interactieve review ───────────────────────────────────────────────────────
|
||
|
||
KEYS = {"h", "a", "s", "q"}
|
||
LABELS = {
|
||
"h": ("houden", "\033[32m"),
|
||
"a": ("afmelden", "\033[31m"),
|
||
"s": ("overslaan", "\033[33m"),
|
||
}
|
||
|
||
|
||
def run_review(lists: list[dict], decisions: dict):
|
||
total = len(lists)
|
||
remaining = [e for e in lists if e["key"] not in decisions]
|
||
|
||
print(f"{'─'*64}")
|
||
print(f" Totaal: {total} | Al beoordeeld: {len(decisions)} | Te gaan: {len(remaining)}")
|
||
print(f" \033[32mh\033[0m houden \033[31ma\033[0m afmelden \033[33ms\033[0m overslaan \033[90mq\033[0m stoppen")
|
||
print(f"{'─'*64}\n")
|
||
|
||
done = sum(1 for e in lists if e["key"] in decisions)
|
||
|
||
for entry in remaining:
|
||
unsub = "✓ beschikbaar" if entry["unsubscribe"] else "✗ handmatig opzoeken"
|
||
domains_str = ", ".join(entry["domains"][:3])
|
||
if len(entry["domains"]) > 3:
|
||
domains_str += f" (+{len(entry['domains'])-3})"
|
||
|
||
print(f"\033[1m[{done+1}/{total}] {entry['name']}\033[0m")
|
||
print(f" \033[90mmails :\033[0m {entry['count']} "
|
||
f"({fmt_date(entry['oldest'])} – {fmt_date(entry['newest'])})")
|
||
print(f" \033[90mdomein :\033[0m {domains_str}")
|
||
print(f" \033[90mmap :\033[0m {entry['folder']}")
|
||
print(f" \033[90mafmeld :\033[0m {unsub}")
|
||
if entry["subjects"]:
|
||
print(f" \033[90mvoorbeeld:\033[0m {entry['subjects'][0][:68]}")
|
||
print()
|
||
print(" > ", end="", flush=True)
|
||
|
||
ch = ""
|
||
while ch not in KEYS:
|
||
ch = getch().lower()
|
||
|
||
if ch == "q":
|
||
print("\n\nGestopt. Voortgang opgeslagen.\n")
|
||
save_decisions(decisions, lists)
|
||
print_summary(decisions)
|
||
return
|
||
|
||
label, color = LABELS[ch]
|
||
print(f"{color}{label}\033[0m\n")
|
||
decisions[entry["key"]] = ch
|
||
done += 1
|
||
save_decisions(decisions, lists)
|
||
|
||
print(f"{'─'*64}")
|
||
print(f"Klaar! Alle {total} lijsten beoordeeld.")
|
||
save_decisions(decisions, lists)
|
||
print_summary(decisions)
|
||
|
||
|
||
def print_summary(decisions: dict):
|
||
a = sum(1 for c in decisions.values() if c == "a")
|
||
h = sum(1 for c in decisions.values() if c == "h")
|
||
s = sum(1 for c in decisions.values() if c == "s")
|
||
print(f"\n Afmelden : {a}")
|
||
print(f" Houden : {h}")
|
||
print(f" Overgeslagen: {s}")
|
||
print(f"\nOpgeslagen in: {DECISIONS_FILE}")
|
||
if a:
|
||
print("Draai daarna: python3 unsubscribe.py")
|
||
|
||
|
||
# ── main ──────────────────────────────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
if CACHE_FILE.exists():
|
||
print(f"Cache gevonden, laden...\n")
|
||
lists = load_cache()
|
||
else:
|
||
lists = build_cache()
|
||
|
||
decisions = load_decisions()
|
||
if decisions:
|
||
active_keys = {e["key"] for e in lists}
|
||
active = {k: v for k, v in decisions.items() if k in active_keys}
|
||
a = sum(1 for c in active.values() if c == "a")
|
||
h = sum(1 for c in active.values() if c == "h")
|
||
s = sum(1 for c in active.values() if c == "s")
|
||
stale = len(decisions) - len(active)
|
||
msg = f"Vorige sessie: {a} afmelden, {h} houden, {s} overgeslagen"
|
||
if stale:
|
||
msg += f" ({stale} verouderde beslissingen genegeerd)"
|
||
print(msg + ".\n")
|
||
|
||
run_review(lists, decisions)
|