Extract shared mail routing and IMAP folder helpers

Prompt since previous commit: execute step 1

Centralized route targets, source-folder policy, and modified UTF-7 mailbox quoting. Verification: py_compile, route/folder consistency, and git diff --check passed.
This commit is contained in:
2026-07-04 12:23:14 +02:00
parent 537c87e472
commit eb53bc123a
8 changed files with 329 additions and 522 deletions
+10 -245
View File
@@ -18,90 +18,15 @@ 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 imaplib
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
from imap_utils import list_folders, quote_mailbox
from mail_routes import PREFIX, is_source_folder, quarter_folder, route_from_subject
LOG_FILE = Path(__file__).parent / "verplaats_log.json"
@@ -109,116 +34,6 @@ 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.
@@ -227,17 +42,6 @@ FACTUUR_KEYWORDS = [
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 ──────────────────────────────────────────────────────────────────
@@ -257,47 +61,8 @@ def decode_hdr(value) -> str:
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))
return [folder for folder in list_folders(mail) if is_source_folder(folder)]
def move_message(mail, uid: bytes, src: str, dst_full: str, dry: bool) -> bool:
@@ -305,8 +70,8 @@ def move_message(mail, uid: bytes, src: str, dst_full: str, dry: bool) -> bool:
if dry:
return True
try:
mail.select(f'"{to_mutf7(src)}"')
res, _ = mail.uid("COPY", uid, f'"{to_mutf7(dst_full)}"')
mail.select(quote_mailbox(src))
res, _ = mail.uid("COPY", uid, quote_mailbox(dst_full))
if res != "OK":
return False
store_res, _ = mail.uid("STORE", uid, "+FLAGS", "\\Deleted")
@@ -329,7 +94,7 @@ def stap1_mapverplaatsingen(mail, log: dict, dry: bool):
continue
# Controleer of bronmap bestaat
status, data = mail.select(f'"{to_mutf7(oud)}"', readonly=True)
status, data = mail.select(quote_mailbox(oud), readonly=True)
if status != "OK":
print(f" bestaat niet: {oud}")
continue
@@ -346,7 +111,7 @@ def stap1_mapverplaatsingen(mail, log: dict, dry: bool):
ids = data[0].split()
gekopieerd = 0
for uid in ids:
res, _ = mail.copy(uid.decode(), f'"{to_mutf7(nieuw_full)}"')
res, _ = mail.copy(uid.decode(), quote_mailbox(nieuw_full))
if res == "OK":
mail.store(uid.decode(), "+FLAGS", "\\Deleted")
gekopieerd += 1
@@ -370,7 +135,7 @@ def stap2_bronmappen_routing(mail, log: dict, dry: bool):
al_verwerkt = set(log.get("stap2_verwerkt", []))
for bron in bron_mappen:
status, data = mail.select(f'"{to_mutf7(bron)}"', readonly=dry)
status, data = mail.select(quote_mailbox(bron), readonly=dry)
if status != "OK":
print(f" Kan map niet openen: {bron}")
continue
@@ -448,7 +213,7 @@ def stap3_facturen(mail, log: dict, dry: bool):
]
for bron in bron_mappen:
status, data = mail.select(f'"{to_mutf7(bron)}"', readonly=dry)
status, data = mail.select(quote_mailbox(bron), readonly=dry)
if status != "OK":
continue