#!/usr/bin/env python3 """ Herindeling van bestaande mail naar de nieuwe mappenstructuur. Stap 1 – Vaste mapverplaatsingen (hele mappen in één keer): Mailinglists/*, Kennis/Coursera, Archief/*/inkomend, enz. Stap 2 – Per-bericht routing (INBOX + overige mappen): Op basis van afzenderdomein en onderwerpkeywords. Stap 3 – Facturen per kwartaal sorteren (Facturen - te verwerken / verwerkt). Standaard: DRY RUN (laat zien wat er zou gebeuren, verplaatst niets). Echt uitvoeren: python3 verplaats_bestaand.py --uitvoeren Log: verplaats_log.json """ import imaplib import base64 import email import email.header import email.utils import re import sys import json import config from pathlib import Path from datetime import datetime, timezone PREFIX = "INBOX." def to_mutf7(s: str) -> str: """Codeer mapnaam naar Modified UTF-7 voor IMAP-aanroepen (RFC 3501).""" res = [] buf = [] for c in s: if 0x20 <= ord(c) <= 0x7e and c != "&": if buf: b64 = ( base64.b64encode("".join(buf).encode("utf-16-be")) .decode("ascii").rstrip("=").replace("/", ",") ) res.append(f"&{b64}-") buf.clear() res.append(c) elif c == "&": if buf: b64 = ( base64.b64encode("".join(buf).encode("utf-16-be")) .decode("ascii").rstrip("=").replace("/", ",") ) res.append(f"&{b64}-") buf.clear() res.append("&-") else: buf.append(c) if buf: b64 = ( base64.b64encode("".join(buf).encode("utf-16-be")) .decode("ascii").rstrip("=").replace("/", ",") ) res.append(f"&{b64}-") return "".join(res) LOG_FILE = Path(__file__).parent / "verplaats_log.json" DRY_RUN = "--uitvoeren" not in sys.argv # ── routeringstabel ────────────────────────────────────────────────────────── # (domain, doelmap) — zelfde logica als mailrules.sieve # Facturen worden apart behandeld (kwartaalsortering). DOMAIN_ROUTES: list[tuple[list[str], str]] = [ # Bank (["nl.abnamro.com", "abnamro.nl"], "Financieel.Bank.ABN AMRO"), (["icscards.nl", "icsmarketing.icscards.nl", "service.icscards.nl"], "Financieel.Bank.ICS"), (["npex.nl"], "Financieel.Bank.NPEX"), (["snelstart.nl"], "Financieel.Bank.SnelStart"), (["tellow.nl"], "Financieel.Bank.Tellow"), # Verzekering (["unive.nl", "nieuwsbrief.unive.nl"], "Financieel.Verzekering.Univé"), (["heinenoord.nl"], "Financieel.Verzekering.VEZA"), # Werk (["flextender.nl"], "Werk.Opdrachten.Flextender"), (["onestopsourcing.nl"], "Werk.Opdrachten.OneStopSourcing"), (["immens.nu"], "Werk.Opdrachten.IMMENS"), (["circle8.nl"], "Werk.Opdrachten.Circle8"), (["getthere.nl"], "Werk.Opdrachten.Get There"), (["huckr.ai"], "Werk.Opdrachten.Huckr"), (["hey.meetjamie.ai"], "Werk.Opdrachten.Jamie"), (["futurexl.nl"], "Werk.Opdrachten.FutureXL"), (["boip.int"], "Werk.Overig.BOIP"), (["groningen.nl", "groningenbereikbaar.nl"], "Werk.Overig.Gemeente Groningen"), # Mobiliteit EV (["eneco-emobility.com", "eneco.nl"], "Mobiliteit.EV.Eneco eMobility"), (["shellrecharge.com"], "Mobiliteit.EV.Shell Recharge"), (["fastned.nl"], "Mobiliteit.EV.FastNed"), (["opngo.com"], "Mobiliteit.EV.OPnGO"), (["tesla.com"], "Mobiliteit.EV.Tesla"), (["mmsn.nl"], "Mobiliteit.EV.Mitsubishi"), (["alfen.com"], "Mobiliteit.EV.Alfen"), # Mobiliteit OV (["ns.nl", "email.ns.nl"], "Mobiliteit.OV & Reizen.NS"), (["booking.com"], "Mobiliteit.OV & Reizen.Booking"), (["flitsmeister.nl"], "Mobiliteit.OV & Reizen.Flitsmeister"), # Bestellingen (["postnl.nl", "edm.postnl.nl", "notificatie.postnl.nl"], "Bestellingen.PostNL"), (["dhlparcel.nl", "dhlecommerce.nl"], "Bestellingen.DHL"), (["gls-netherlands.com"], "Bestellingen.GLS"), (["bol.com", "feedback.bol.com"], "Bestellingen.bol"), (["coolblue.nl", "coolblue.eu"], "Bestellingen.Coolblue"), (["allekabels.nl"], "Bestellingen.Allekabels"), (["vikingdirect.nl", "delivery.vikingdirect.nl", "online.vikingdirect.nl"], "Bestellingen.Viking"), (["123inkt.nl", "m.123inkt.nl"], "Bestellingen.123inkt"), (["officecentre.nl"], "Bestellingen.Office Centre"), (["sligro.nl"], "Bestellingen.Sligro"), (["makro.nl"], "Bestellingen.Makro"), (["smartphonehoesjes.nl"], "Bestellingen.Smartphonehoesjes"), # Diensten Hosting (["strato.com", "news.strato.com", "marketingradar.strato.com"], "Diensten.Hosting.STRATO"), (["transip.nl"], "Diensten.Hosting.TransIP"), (["letsencrypt.org"], "Diensten.Hosting.Lets Encrypt"), (["backblaze.com"], "Diensten.Hosting.Backblaze"), # Diensten Telecom (["kpn.com", "campagnes.kpn.com"], "Diensten.Telecom.KPN"), (["zakelijk.vodafone.nl", "vodafone.nl"], "Diensten.Telecom.Vodafone"), # Diensten Software (["cursor.com"], "Diensten.Software.Cursor"), (["1password.com"], "Diensten.Software.1Password"), (["home.sophos.com", "cleverbridge.com"], "Diensten.Software.Sophos"), (["cogsciapps.com"], "Diensten.Software.CogSci Apps"), (["claude.com"], "Diensten.Software.Claude"), (["microsoft365.com"], "Diensten.Software.Microsoft Teams"), (["getmatter.com"], "Diensten.Software.Matter"), (["mindnode.com"], "Diensten.Software.MindNode"), (["papersapp.com"], "Diensten.Software.Papers"), (["team.kahoot.com"], "Diensten.Software.Kahoot"), (["perplexity.ai"], "Diensten.Software.Perplexity"), # Nieuwsbrieven Leren (["coursera.org", "email.coursera.org", "m.mail.coursera.org", "m.learn.coursera.org"], "Nieuwsbrieven.Leren.Coursera"), (["technologyreview.com", "fulfillment.technologyreview.com"], "Nieuwsbrieven.Leren.MIT Technology Review"), (["mit.edu"], "Nieuwsbrieven.Leren.MIT Open Learning"), (["storytellingwithdata.com"], "Nieuwsbrieven.Leren.Storytelling with Data"), (["linkingyourthinking.com"], "Nieuwsbrieven.Leren.Nick Milo"), (["evakeiffenheim.com"], "Nieuwsbrieven.Leren.Eva Keiffenheim"), (["tiggelaar.nl"], "Nieuwsbrieven.Leren.Ben Tiggelaar"), (["briankeating.com"], "Nieuwsbrieven.Leren.Brian Keating"), (["beehiiv.com", "mail.beehiiv.com"], "Nieuwsbrieven.Leren.AI Report"), (["elizabethbutlermd.com"], "Nieuwsbrieven.Leren.Elizabeth Butler"), # Nieuwsbrieven Nieuws & Vakbladen (["crisismanager.nl"], "Nieuwsbrieven.Nieuws & Vakbladen.Vakblad Crisismanager"), (["jaarbeurs.nl"], "Nieuwsbrieven.Nieuws & Vakbladen.Computable"), (["presentation-guru.com"], "Nieuwsbrieven.Nieuws & Vakbladen.The Presentation Guru"), # Nieuwsbrieven Overig (["lendahand.com"], "Nieuwsbrieven.Overig.Lendahand"), (["thegoodroll.nl"], "Nieuwsbrieven.Overig.The Good Roll"), # Technisch (["dmarc.postmarkapp.com"], "Technisch.DMARC"), ] # Bouw snelle lookup: domain → doelmap DOMAIN_LOOKUP: dict[str, str] = {} for domains, target in DOMAIN_ROUTES: for d in domains: DOMAIN_LOOKUP[d.lower()] = target FACTUUR_KEYWORDS = [ "factuur", "invoice", "rekening", "nota", "betaalverzoek", "receipt", "orderbevestiging", "order confirmation", "betaling ontvangen", "payment received", "uw factuur", "your invoice", "je factuur", ] # Mappen die in één keer worden hernoemd/verplaatst (oud → nieuw) MAP_RENAMES: list[tuple[str, str]] = [ ("INBOX.Mailinglists.Ben Tiggelaar", "INBOX.Nieuwsbrieven.Leren.Ben Tiggelaar"), ("INBOX.Mailinglists.Brian Keating", "INBOX.Nieuwsbrieven.Leren.Brian Keating"), ("INBOX.Mailinglists.Eva Keiffenheim", "INBOX.Nieuwsbrieven.Leren.Eva Keiffenheim"), ("INBOX.Mailinglists.Lendahand", "INBOX.Nieuwsbrieven.Overig.Lendahand"), ("INBOX.Mailinglists.LYT - Nick Milo", "INBOX.Nieuwsbrieven.Leren.Nick Milo"), ("INBOX.Mailinglists.MIT Technology Review", "INBOX.Nieuwsbrieven.Leren.MIT Technology Review"), ("INBOX.Mailinglists.Storytelling with Data", "INBOX.Nieuwsbrieven.Leren.Storytelling with Data"), ("INBOX.Kennis.Coursera", "INBOX.Nieuwsbrieven.Leren.Coursera"), # Archief: verwijder inkomend/verzonden laag ("INBOX.Archief.2020.inkomend", "INBOX.Archief.2020"), ("INBOX.Archief.2021.inkomend", "INBOX.Archief.2021"), ("INBOX.Archief.2022.inkomend", "INBOX.Archief.2022"), # Verzonden archief ook samenvoegen ("INBOX.Archief.2020.verzonden", "INBOX.Archief.2020"), ("INBOX.Archief.2021.verzonden", "INBOX.Archief.2021"), ("INBOX.Archief.2022.verzonden", "INBOX.Archief.2022"), # Facturen omzetten (per-kwartaal via stap 3) ("INBOX.Facturen - te verwerken", "__FACTUREN__"), ("INBOX.Facturen - verwerkt", "__FACTUREN__"), ] # Mappen waarvan de inhoud per bericht gerouteerd moet worden ROUTE_FOLDERS = ["INBOX"] # ── 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 sender_domain(from_addr: str) -> str: m = re.search(r"@([\w.\-]+)", from_addr) return m.group(1).lower() if m else "" def quarter_folder(date_str: str) -> str: """Geef de doelmap terug op basis van de e-maildatum.""" try: dt = email.utils.parsedate_to_datetime(date_str) jaar = dt.year maand = dt.month kwartaal = (maand - 1) // 3 + 1 return f"Financieel.Facturen.{jaar}.Q{kwartaal}" except Exception: jaar = datetime.now().year return f"Financieel.Facturen.{jaar}.Q1" def route_from_subject(from_addr: str, subject: str) -> str | None: """Bepaal doelmap op basis van afzender en onderwerp. None = geen match.""" domain = sender_domain(from_addr) # Facturen hebben prioriteit subj_lo = subject.lower() if any(kw in subj_lo for kw in FACTUUR_KEYWORDS): return "__FACTUUR_DATE__" # behandeld apart (datum nodig) return DOMAIN_LOOKUP.get(domain) def move_message(mail, uid: bytes, src: str, dst_full: str, dry: bool) -> bool: """Kopieer bericht naar dst en markeer als verwijderd in src.""" if dry: return True try: mail.select(f'"{to_mutf7(src)}"') res, _ = mail.uid("COPY", uid, f'"{to_mutf7(dst_full)}"') if res == "OK": mail.uid("STORE", uid, "+FLAGS", "\\Deleted") return res == "OK" except Exception as e: print(f" FOUT bij verplaatsen: {e}") return False # ── stap 1: vaste mapverplaatsingen ───────────────────────────────────────── def stap1_mapverplaatsingen(mail, log: dict, dry: bool): print("\n── Stap 1: vaste mapverplaatsingen ──") for oud, nieuw in MAP_RENAMES: if nieuw == "__FACTUREN__": continue # wordt in stap 3 verwerkt if oud in log.get("stap1_klaar", []): print(f" ✓ al gedaan: {oud}") continue # Controleer of bronmap bestaat status, data = mail.select(f'"{to_mutf7(oud)}"', readonly=True) if status != "OK": print(f" – bestaat niet: {oud}") continue n = int(data[0]) nieuw_full = nieuw if nieuw.startswith("INBOX.") else PREFIX + nieuw if dry: print(f" [DRY] {oud} → {nieuw} ({n} berichten)") continue # Berichten kopiëren status, data = mail.search(None, "ALL") ids = data[0].split() gekopieerd = 0 for uid in ids: res, _ = mail.copy(uid.decode(), f'"{to_mutf7(nieuw_full)}"') if res == "OK": mail.store(uid.decode(), "+FLAGS", "\\Deleted") gekopieerd += 1 mail.expunge() print(f" ✓ {oud} → {nieuw} ({gekopieerd}/{n})") log.setdefault("stap1_klaar", []).append(oud) _save_log(log) # ── stap 2: per-bericht routing (INBOX) ───────────────────────────────────── def stap2_inbox_routing(mail, log: dict, dry: bool): print("\n── Stap 2: INBOX per-bericht routing ──") status, data = mail.select('"INBOX"', readonly=dry) if status != "OK": print(" Kan INBOX niet openen.") return n = int(data[0]) print(f" {n} berichten in INBOX") # UID-gebaseerde search: stabiel ook na expunge status, data = mail.uid("SEARCH", "ALL") if status != "OK" or not data or not data[0]: print(" Geen berichten gevonden (search mislukt of leeg).") return ids = [uid for uid in data[0].split() if uid] verplaatst = 0 geen_match = 0 al_verwerkt = set(log.get("stap2_verwerkt", [])) for uid in ids: uid_str = uid.decode() if uid_str in al_verwerkt: continue # Haal alleen headers op via UID status, data = mail.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])") if status != "OK" or not data or not data[0]: continue msg = email.message_from_bytes(data[0][1]) from_raw = decode_hdr(msg.get("From", "")) subject = decode_hdr(msg.get("Subject", "")) date_str = msg.get("Date", "") doel = route_from_subject(from_raw, subject) if doel == "__FACTUUR_DATE__": doel = PREFIX + quarter_folder(date_str) elif doel: doel = PREFIX + doel else: geen_match += 1 continue if dry: print(f" [DRY] → {doel} | {subject[:60]}") else: ok = move_message(mail, uid, "INBOX", doel, dry=False) if ok: verplaatst += 1 log.setdefault("stap2_verwerkt", []).append(uid_str) if verplaatst % 50 == 0: mail.expunge() _save_log(log) if not dry: mail.expunge() _save_log(log) print(f" Verplaatst: {verplaatst} | Geen match (blijft in INBOX): {geen_match}") # ── stap 3: facturen per kwartaal ──────────────────────────────────────────── def stap3_facturen(mail, log: dict, dry: bool): print("\n── Stap 3: facturen per kwartaal ──") bron_mappen = [ "INBOX.Facturen - te verwerken", "INBOX.Facturen - verwerkt", "INBOX.Financieel.Facturen", # als ze er al in zitten ] for bron in bron_mappen: status, data = mail.select(f'"{to_mutf7(bron)}"', readonly=dry) if status != "OK": continue n = int(data[0]) if n == 0: print(f" {bron}: leeg") continue print(f" {bron}: {n} berichten") status, data = mail.uid("SEARCH", "ALL") if status != "OK" or not data or not data[0]: continue ids = [uid for uid in data[0].split() if uid] verplaatst = 0 for uid in ids: status, data = mail.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (DATE SUBJECT)])") if status != "OK" or not data or not data[0]: continue msg = email.message_from_bytes(data[0][1]) date_str = msg.get("Date", "") doel = PREFIX + quarter_folder(date_str) if dry: dt_kort = date_str[:16] if date_str else "?" print(f" [DRY] {dt_kort} → {doel}") else: ok = move_message(mail, uid, bron, doel, dry=False) if ok: verplaatst += 1 if not dry: mail.expunge() print(f" {verplaatst}/{n} verplaatst") # ── log ────────────────────────────────────────────────────────────────────── def _save_log(log: dict): LOG_FILE.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8") def load_log() -> dict: if LOG_FILE.exists(): try: return json.loads(LOG_FILE.read_text(encoding="utf-8")) except Exception: pass return {} # ── main ────────────────────────────────────────────────────────────────────── def run(): if DRY_RUN: print("DRY RUN — er wordt niets verplaatst.") print("Voeg --uitvoeren toe om echt uit te voeren.\n") else: print("LET OP: dit verplaatst berichten op de echte server.") bevestig = input("Typ JA om door te gaan: ").strip() if bevestig != "JA": print("Afgebroken.") sys.exit(0) cfg = config.load() print(f"Account: {cfg.username}\n") print(f"Verbinden met {cfg.imap_host}:{cfg.imap_port} ...") try: mail = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port) mail.login(cfg.username, cfg.password) except Exception as e: print(f"Verbinding mislukt: {e}") sys.exit(1) log = load_log() stap1_mapverplaatsingen(mail, log, DRY_RUN) stap2_inbox_routing(mail, log, DRY_RUN) stap3_facturen(mail, log, DRY_RUN) mail.logout() print("\nKlaar.") if DRY_RUN: print("Draai met --uitvoeren om de wijzigingen door te voeren.") if __name__ == "__main__": run()