#!/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)