#!/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 email import email.header import imaplib import sys import json import config from pathlib import Path 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" DRY_RUN = "--uitvoeren" not in sys.argv # 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]] = [ ] # ── 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 list_source_folders(mail) -> list[str]: 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: """Kopieer bericht naar dst en markeer als verwijderd in src.""" if dry: return True try: 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") 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(quote_mailbox(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(), quote_mailbox(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(quote_mailbox(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(quote_mailbox(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()