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:
+10
-245
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user