Files
mailcat/verplaats_bestaand.py
T
wienen 4780c18d70 Initial commit
Claude has done a lot of work, but there are still some errors which
cause the mails to be distributed to wrong mail boxes. I am now going to
let codex and claude compete. They will have their own branches and
merge to main as soon as we are somewhere where we can switch AI.
2026-07-04 11:18:05 +02:00

482 lines
20 KiB
Python
Raw 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
"""
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()