#!/usr/bin/env python3 """ Herindeling van bestaande mail naar de nieuwe mappenstructuur. Stap 1 – Vaste mapverplaatsingen: Uitgeschakeld. Alle niet-uitgesloten mappen worden als bron behandeld. Stap 2 – Per-bericht routing: Op basis van afzenderdomein en onderwerpkeywords. Bronmappen: alles behalve Sent, Drafts, Trash, Spam en INBOX.Facturen - verwerkt. Stap 3 – Facturen per kwartaal sorteren (Facturen - te verwerken). 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) def from_mutf7(s: str) -> str: """Decodeer een mUTF-7-mapnaam (van server) terug naar Unicode.""" res = [] i = 0 while i < len(s): if s[i] == "&": j = s.index("-", i + 1) if j == i + 1: res.append("&") else: encoded = s[i + 1 : j].replace(",", "/") pad = (4 - len(encoded) % 4) % 4 res.append( base64.b64decode(encoded + "=" * pad).decode("utf-16-be") ) i = j + 1 else: res.append(s[i]) i += 1 return "".join(res) def parse_folder_name(raw: bytes) -> str | None: """Haal mapnaam op uit IMAP LIST-response en decodeer mUTF-7.""" decoded = raw.decode("ascii", errors="replace") match = re.match(r'\(.*?\)\s+".*?"\s+(.*)', decoded) if not match: return None name = match.group(1).strip().strip('"') if not name: return None try: return from_mutf7(name) except Exception: return name 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). # # Bewust leeg: oude mappen, archiefmappen en mailinglistmappen zijn bronnen. # Alleen Sent, Drafts, Trash, Spam en INBOX.Facturen - verwerkt worden # uitgesloten van routering. MAP_RENAMES: list[tuple[str, str]] = [ ] EXCLUDED_SOURCE_FOLDERS = { "INBOX.Sent", "Sent", "INBOX.Drafts", "Drafts", "INBOX.Trash", "Trash", "INBOX.Spam", "Spam", } ALREADY_SORTED_FOLDERS = { "INBOX.Facturen - verwerkt", } # ── 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 is_source_folder(folder: str) -> bool: """True als deze map als bron voor routering gebruikt mag worden.""" return folder not in EXCLUDED_SOURCE_FOLDERS and folder not in ALREADY_SORTED_FOLDERS def list_source_folders(mail) -> list[str]: status, raw_folders = mail.list() if status != "OK" or not raw_folders: return [] folders = [parse_folder_name(item) for item in raw_folders if item] return sorted(f for f in folders if f and is_source_folder(f)) 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": return False store_res, _ = mail.uid("STORE", uid, "+FLAGS", "\\Deleted") return store_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_bronmappen_routing(mail, log: dict, dry: bool): print("\n── Stap 2: bronmappen per-bericht routing ──") bron_mappen = list_source_folders(mail) print(f" {len(bron_mappen)} bronmappen") totaal_verplaatst = 0 totaal_geen_match = 0 al_verwerkt = set(log.get("stap2_verwerkt", [])) for bron in bron_mappen: status, data = mail.select(f'"{to_mutf7(bron)}"', readonly=dry) if status != "OK": print(f" Kan map niet openen: {bron}") continue n = int(data[0]) if n == 0: 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 geen_match = 0 for uid in ids: uid_str = uid.decode() log_key = f"{bron}:{uid_str}" if log_key in al_verwerkt: continue 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 bron == doel: continue if dry: print(f" [DRY] {bron} → {doel} | {subject[:60]}") else: ok = move_message(mail, uid, bron, doel, dry=False) if ok: verplaatst += 1 log.setdefault("stap2_verwerkt", []).append(log_key) if verplaatst % 50 == 0: mail.expunge() _save_log(log) if not dry: mail.expunge() _save_log(log) totaal_verplaatst += verplaatst totaal_geen_match += geen_match print(f" Verplaatst: {verplaatst} | Geen match: {geen_match}") print(f" Totaal verplaatst: {totaal_verplaatst} | Totaal geen match: {totaal_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.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_bronmappen_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()