diff --git a/maak_mappen.py b/maak_mappen.py index bef5759..c82074e 100644 --- a/maak_mappen.py +++ b/maak_mappen.py @@ -185,11 +185,9 @@ def mappen() -> list[str]: "Bestellingen.Sligro", "Bestellingen.Smartphonehoesjes", "Bestellingen.Viking", - # Archief nieuwe jaren - "Archief.2023", - "Archief.2024", - "Archief.2025", - "Archief.2026", + # Technisch + "Technisch", + "Technisch.DMARC", ] return folders diff --git a/verplaats_bestaand.py b/verplaats_bestaand.py index b4b24dc..b51b6b9 100644 --- a/verplaats_bestaand.py +++ b/verplaats_bestaand.py @@ -2,13 +2,15 @@ """ Herindeling van bestaande mail naar de nieuwe mappenstructuur. -Stap 1 – Vaste mapverplaatsingen (hele mappen in één keer): - Mailinglists/*, Kennis/Coursera, Archief/*/inkomend, enz. +Stap 1 – Vaste mapverplaatsingen: + Uitgeschakeld. Alle niet-uitgesloten mappen worden als bron behandeld. -Stap 2 – Per-bericht routing (INBOX + overige mappen): +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 / 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 @@ -63,6 +65,45 @@ def to_mutf7(s: str) -> str: ) 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 @@ -178,31 +219,24 @@ FACTUUR_KEYWORDS = [ "uw factuur", "your invoice", "je factuur", ] -# Mappen die in één keer worden hernoemd/verplaatst (oud → nieuw) +# 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]] = [ - ("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"] +EXCLUDED_SOURCE_FOLDERS = { + "INBOX.Sent", "Sent", + "INBOX.Drafts", "Drafts", + "INBOX.Trash", "Trash", + "INBOX.Spam", "Spam", +} + +ALREADY_SORTED_FOLDERS = { + "INBOX.Facturen - verwerkt", +} # ── helpers ────────────────────────────────────────────────────────────────── @@ -253,6 +287,19 @@ def route_from_subject(from_addr: str, subject: str) -> str | None: 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: @@ -260,9 +307,10 @@ def move_message(mail, uid: bytes, src: str, dst_full: str, dry: bool) -> bool: 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" + 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 @@ -312,69 +360,82 @@ def stap1_mapverplaatsingen(mail, log: dict, dry: bool): # ── 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 +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 uid in ids: - uid_str = uid.decode() - if uid_str in al_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 - # Haal alleen headers op via UID - status, data = mail.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])") + 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] - 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", "") + verplaatst = 0 + geen_match = 0 - doel = route_from_subject(from_raw, subject) + for uid in ids: + uid_str = uid.decode() + log_key = f"{bron}:{uid_str}" + if log_key in al_verwerkt: + continue - if doel == "__FACTUUR_DATE__": - doel = PREFIX + quarter_folder(date_str) - elif doel: - doel = PREFIX + doel - else: - geen_match += 1 - 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 - 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) + 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", "") - if not dry: - mail.expunge() - _save_log(log) + doel = route_from_subject(from_raw, subject) - print(f" Verplaatst: {verplaatst} | Geen match (blijft in INBOX): {geen_match}") + 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 ──────────────────────────────────────────── @@ -383,7 +444,6 @@ 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 ] @@ -468,7 +528,7 @@ def run(): log = load_log() stap1_mapverplaatsingen(mail, log, DRY_RUN) - stap2_inbox_routing(mail, log, DRY_RUN) + stap2_bronmappen_routing(mail, log, DRY_RUN) stap3_facturen(mail, log, DRY_RUN) mail.logout()