616 lines
20 KiB
Python
616 lines
20 KiB
Python
#!/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).
|
||
Account kiezen: python3 verplaats_bestaand.py --account backup --audit
|
||
Echt uitvoeren: python3 verplaats_bestaand.py --account backup --uitvoeren
|
||
|
||
Log: verplaats_log.json
|
||
"""
|
||
|
||
import email
|
||
import email.header
|
||
import imaplib
|
||
import sys
|
||
import json
|
||
import config
|
||
import time
|
||
from pathlib import Path
|
||
from collections import Counter
|
||
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
|
||
AUDIT = "--audit" in sys.argv
|
||
PROGRESS_INTERVAL = 250
|
||
|
||
|
||
# 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)]
|
||
|
||
|
||
class ImapSession:
|
||
"""IMAP connection wrapper with conservative reconnect support."""
|
||
|
||
def __init__(self, cfg):
|
||
self.cfg = cfg
|
||
self.mail = None
|
||
|
||
def connect(self):
|
||
self.mail = imaplib.IMAP4_SSL(self.cfg.imap_host, self.cfg.imap_port)
|
||
self.mail.login(self.cfg.username, self.cfg.password)
|
||
|
||
def reconnect(self):
|
||
if self.mail is not None:
|
||
try:
|
||
self.mail.logout()
|
||
except Exception:
|
||
pass
|
||
time.sleep(2)
|
||
self.connect()
|
||
|
||
def logout(self):
|
||
if self.mail is not None:
|
||
self.mail.logout()
|
||
|
||
|
||
def log_failure(
|
||
log: dict,
|
||
stap: str,
|
||
src: str,
|
||
uid: bytes | str,
|
||
dst: str | None,
|
||
actie: str,
|
||
status: str | None,
|
||
response,
|
||
):
|
||
uid_str = uid.decode(errors="replace") if isinstance(uid, bytes) else str(uid)
|
||
log.setdefault("fouten", []).append({
|
||
"stap": stap,
|
||
"bron": src,
|
||
"uid": uid_str,
|
||
"doel": dst,
|
||
"actie": actie,
|
||
"status": status,
|
||
"response": repr(response),
|
||
})
|
||
|
||
|
||
def print_audit(title: str, by_source: Counter, by_destination: Counter):
|
||
print(f"\n Audit: {title}")
|
||
print(" Per bronmap:")
|
||
for folder, count in by_source.most_common():
|
||
print(f" {count:5d} {folder}")
|
||
print(" Per doelmap:")
|
||
for folder, count in by_destination.most_common():
|
||
print(f" {count:5d} {folder}")
|
||
|
||
|
||
def print_progress(folder: str, processed: int, total: int):
|
||
if processed == total or processed % PROGRESS_INTERVAL == 0:
|
||
print(f" Gescand: {processed}/{total} | {folder}")
|
||
|
||
|
||
def fetch_body(data) -> bytes | None:
|
||
"""Return the first bytes payload from an IMAP FETCH response."""
|
||
if not data:
|
||
return None
|
||
for item in data:
|
||
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], bytes):
|
||
return item[1]
|
||
return None
|
||
|
||
|
||
def safe_select(session: ImapSession, log: dict, stap: str, folder: str, readonly: bool):
|
||
for attempt in range(2):
|
||
try:
|
||
return session.mail.select(quote_mailbox(folder), readonly=readonly)
|
||
except imaplib.IMAP4.abort as e:
|
||
log_failure(log, stap, folder, "", None, "select_abort", "ABORT", str(e))
|
||
if attempt == 1:
|
||
return "ABORT", [str(e).encode()]
|
||
print(f" IMAP-verbinding verbroken bij openen van {folder}; opnieuw verbinden ...")
|
||
try:
|
||
session.reconnect()
|
||
except Exception as reconnect_error:
|
||
log_failure(
|
||
log,
|
||
stap,
|
||
folder,
|
||
"",
|
||
None,
|
||
"reconnect_failed",
|
||
"ERROR",
|
||
str(reconnect_error),
|
||
)
|
||
return "ABORT", [str(reconnect_error).encode()]
|
||
|
||
|
||
def safe_uid(
|
||
session: ImapSession,
|
||
log: dict,
|
||
stap: str,
|
||
folder: str,
|
||
uid: bytes | str,
|
||
command: str,
|
||
*args,
|
||
readonly: bool,
|
||
):
|
||
for attempt in range(2):
|
||
try:
|
||
return session.mail.uid(command, *args)
|
||
except imaplib.IMAP4.abort as e:
|
||
log_failure(
|
||
log,
|
||
stap,
|
||
folder,
|
||
uid,
|
||
None,
|
||
f"uid_{command.lower()}_abort",
|
||
"ABORT",
|
||
str(e),
|
||
)
|
||
if attempt == 1:
|
||
return "ABORT", [str(e).encode()]
|
||
print(f" IMAP-verbinding verbroken in {folder}; opnieuw verbinden en opnieuw proberen ...")
|
||
try:
|
||
session.reconnect()
|
||
except Exception as reconnect_error:
|
||
log_failure(
|
||
log,
|
||
stap,
|
||
folder,
|
||
uid,
|
||
None,
|
||
"reconnect_failed",
|
||
"ERROR",
|
||
str(reconnect_error),
|
||
)
|
||
return "ABORT", [str(reconnect_error).encode()]
|
||
status, data = safe_select(session, log, stap, folder, readonly=readonly)
|
||
if status != "OK":
|
||
return status, data
|
||
|
||
|
||
def safe_list_source_folders(session: ImapSession, log: dict) -> list[str]:
|
||
for attempt in range(2):
|
||
try:
|
||
return list_source_folders(session.mail)
|
||
except imaplib.IMAP4.abort as e:
|
||
log_failure(log, "stap2", "", "", None, "list_abort", "ABORT", str(e))
|
||
if attempt == 1:
|
||
return []
|
||
print(" IMAP-verbinding verbroken bij ophalen van mappen; opnieuw verbinden ...")
|
||
try:
|
||
session.reconnect()
|
||
except Exception as reconnect_error:
|
||
log_failure(
|
||
log,
|
||
"stap2",
|
||
"",
|
||
"",
|
||
None,
|
||
"reconnect_failed",
|
||
"ERROR",
|
||
str(reconnect_error),
|
||
)
|
||
return []
|
||
|
||
|
||
def move_message(mail, uid: bytes, src: str, dst_full: str, dry: bool):
|
||
"""Kopieer bericht naar dst en markeer als verwijderd in src."""
|
||
if dry:
|
||
return True, None
|
||
try:
|
||
select_res, select_data = mail.select(quote_mailbox(src))
|
||
if select_res != "OK":
|
||
return False, ("select_source", select_res, select_data)
|
||
res, copy_data = mail.uid("COPY", uid, quote_mailbox(dst_full))
|
||
if res != "OK":
|
||
return False, ("copy", res, copy_data)
|
||
store_res, store_data = mail.uid("STORE", uid, "+FLAGS", "\\Deleted")
|
||
if store_res != "OK":
|
||
return False, ("store_deleted", store_res, store_data)
|
||
return True, None
|
||
except Exception as e:
|
||
print(f" FOUT bij verplaatsen: {e}")
|
||
return False, ("exception", None, str(e))
|
||
|
||
|
||
# ── stap 1: vaste mapverplaatsingen ─────────────────────────────────────────
|
||
|
||
def stap1_mapverplaatsingen(session: ImapSession, log: dict, dry: bool):
|
||
mail = session.mail
|
||
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 = safe_select(session, log, "stap1", 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(session: ImapSession, log: dict, dry: bool):
|
||
mail = session.mail
|
||
print("\n── Stap 2: bronmappen per-bericht routing ──")
|
||
bron_mappen = safe_list_source_folders(session, log)
|
||
print(f" {len(bron_mappen)} bronmappen")
|
||
|
||
audit_by_source = Counter()
|
||
audit_by_destination = Counter()
|
||
totaal_verplaatst = 0
|
||
totaal_gepland = 0
|
||
totaal_geen_match = 0
|
||
totaal_overgeslagen = 0
|
||
totaal_mislukt = 0
|
||
al_verwerkt = set(log.get("stap2_verwerkt", []))
|
||
|
||
for folder_index, bron in enumerate(bron_mappen, start=1):
|
||
status, data = safe_select(session, log, "stap2", bron, readonly=dry)
|
||
mail = session.mail
|
||
if status != "OK":
|
||
print(f" Kan map niet openen: {bron}")
|
||
continue
|
||
|
||
n = int(data[0])
|
||
if n == 0:
|
||
continue
|
||
print(f" [{folder_index}/{len(bron_mappen)}] {bron}: {n} berichten")
|
||
|
||
status, data = safe_uid(
|
||
session, log, "stap2", bron, "", "SEARCH", "ALL", readonly=dry
|
||
)
|
||
mail = session.mail
|
||
if status != "OK" or not data or not data[0]:
|
||
continue
|
||
ids = [uid for uid in data[0].split() if uid]
|
||
|
||
verplaatst = 0
|
||
gepland = 0
|
||
geen_match = 0
|
||
overgeslagen = 0
|
||
mislukt = 0
|
||
verwerkt = 0
|
||
|
||
for uid in ids:
|
||
verwerkt += 1
|
||
if AUDIT:
|
||
print_progress(bron, verwerkt, len(ids))
|
||
uid_str = uid.decode()
|
||
log_key = f"{bron}:{uid_str}"
|
||
if log_key in al_verwerkt:
|
||
overgeslagen += 1
|
||
continue
|
||
|
||
status, data = safe_uid(
|
||
session,
|
||
log,
|
||
"stap2",
|
||
bron,
|
||
uid,
|
||
"FETCH",
|
||
uid,
|
||
"(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])",
|
||
readonly=dry,
|
||
)
|
||
mail = session.mail
|
||
raw_header = fetch_body(data)
|
||
if status != "OK" or raw_header is None:
|
||
mislukt += 1
|
||
log_failure(log, "stap2", bron, uid, None, "fetch_header", status, data)
|
||
continue
|
||
|
||
msg = email.message_from_bytes(raw_header)
|
||
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:
|
||
overgeslagen += 1
|
||
continue
|
||
|
||
gepland += 1
|
||
audit_by_source[bron] += 1
|
||
audit_by_destination[doel] += 1
|
||
if dry:
|
||
if not AUDIT:
|
||
print(f" [DRY {verwerkt}/{len(ids)}] {bron} → {doel} | {subject[:60]}")
|
||
else:
|
||
ok, failure = 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)
|
||
else:
|
||
mislukt += 1
|
||
actie, status, response = failure
|
||
log_failure(log, "stap2", bron, uid, doel, actie, status, response)
|
||
|
||
if not dry:
|
||
mail.expunge()
|
||
_save_log(log)
|
||
|
||
totaal_verplaatst += verplaatst
|
||
totaal_gepland += gepland
|
||
totaal_geen_match += geen_match
|
||
totaal_overgeslagen += overgeslagen
|
||
totaal_mislukt += mislukt
|
||
if dry:
|
||
print(
|
||
f" Gepland: {gepland} | Geen match: {geen_match}"
|
||
f" | Overgeslagen: {overgeslagen} | Fouten: {mislukt}"
|
||
)
|
||
else:
|
||
print(
|
||
f" Gepland: {gepland} | Verplaatst: {verplaatst}"
|
||
f" | Geen match: {geen_match} | Overgeslagen: {overgeslagen}"
|
||
f" | Fouten: {mislukt}"
|
||
)
|
||
|
||
if dry:
|
||
print(
|
||
f" Totaal gepland: {totaal_gepland} | Totaal geen match: {totaal_geen_match}"
|
||
f" | Totaal overgeslagen: {totaal_overgeslagen} | Totaal fouten: {totaal_mislukt}"
|
||
)
|
||
if AUDIT:
|
||
print_audit("stap 2 geplande verplaatsingen", audit_by_source, audit_by_destination)
|
||
else:
|
||
print(
|
||
f" Totaal gepland: {totaal_gepland} | Totaal verplaatst: {totaal_verplaatst}"
|
||
f" | Totaal geen match: {totaal_geen_match} | Totaal overgeslagen: {totaal_overgeslagen}"
|
||
f" | Totaal fouten: {totaal_mislukt}"
|
||
)
|
||
|
||
|
||
# ── stap 3: facturen per kwartaal ────────────────────────────────────────────
|
||
|
||
def stap3_facturen(session: ImapSession, log: dict, dry: bool):
|
||
mail = session.mail
|
||
print("\n── Stap 3: facturen per kwartaal ──")
|
||
bron_mappen = [
|
||
"INBOX.Facturen - te verwerken",
|
||
"INBOX.Financieel.Facturen", # als ze er al in zitten
|
||
]
|
||
audit_by_source = Counter()
|
||
audit_by_destination = Counter()
|
||
|
||
for folder_index, bron in enumerate(bron_mappen, start=1):
|
||
status, data = safe_select(session, log, "stap3", bron, readonly=dry)
|
||
mail = session.mail
|
||
if status != "OK":
|
||
continue
|
||
|
||
n = int(data[0])
|
||
if n == 0:
|
||
print(f" [{folder_index}/{len(bron_mappen)}] {bron}: leeg")
|
||
continue
|
||
|
||
print(f" [{folder_index}/{len(bron_mappen)}] {bron}: {n} berichten")
|
||
status, data = safe_uid(
|
||
session, log, "stap3", bron, "", "SEARCH", "ALL", readonly=dry
|
||
)
|
||
mail = session.mail
|
||
if status != "OK" or not data or not data[0]:
|
||
continue
|
||
ids = [uid for uid in data[0].split() if uid]
|
||
verplaatst = 0
|
||
gepland = 0
|
||
mislukt = 0
|
||
|
||
for index, uid in enumerate(ids, start=1):
|
||
if AUDIT:
|
||
print_progress(bron, index, len(ids))
|
||
status, data = safe_uid(
|
||
session,
|
||
log,
|
||
"stap3",
|
||
bron,
|
||
uid,
|
||
"FETCH",
|
||
uid,
|
||
"(BODY.PEEK[HEADER.FIELDS (DATE SUBJECT)])",
|
||
readonly=dry,
|
||
)
|
||
mail = session.mail
|
||
raw_header = fetch_body(data)
|
||
if status != "OK" or raw_header is None:
|
||
mislukt += 1
|
||
log_failure(log, "stap3", bron, uid, None, "fetch_header", status, data)
|
||
continue
|
||
|
||
msg = email.message_from_bytes(raw_header)
|
||
date_str = msg.get("Date", "")
|
||
doel = PREFIX + quarter_folder(date_str)
|
||
gepland += 1
|
||
audit_by_source[bron] += 1
|
||
audit_by_destination[doel] += 1
|
||
|
||
if dry:
|
||
dt_kort = date_str[:16] if date_str else "?"
|
||
if not AUDIT:
|
||
print(f" [DRY {index}/{len(ids)}] {dt_kort} → {doel}")
|
||
else:
|
||
ok, failure = move_message(mail, uid, bron, doel, dry=False)
|
||
if ok:
|
||
verplaatst += 1
|
||
else:
|
||
mislukt += 1
|
||
actie, status, response = failure
|
||
log_failure(log, "stap3", bron, uid, doel, actie, status, response)
|
||
|
||
if not dry:
|
||
mail.expunge()
|
||
print(f" Gepland: {gepland} | Verplaatst: {verplaatst} | Fouten: {mislukt}")
|
||
_save_log(log)
|
||
else:
|
||
print(f" Gepland: {gepland} | Fouten: {mislukt}")
|
||
|
||
if dry and AUDIT:
|
||
print_audit("stap 3 geplande factuurverplaatsingen", audit_by_source, audit_by_destination)
|
||
|
||
|
||
# ── 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 {}
|
||
|
||
|
||
def account_name_from_args() -> str | None:
|
||
args = sys.argv[1:]
|
||
if "--account" not in args:
|
||
return None
|
||
|
||
idx = args.index("--account")
|
||
if idx + 1 >= len(args) or args[idx + 1].startswith("--"):
|
||
print("Gebruik: python3 verplaats_bestaand.py --account <naam> [--audit|--uitvoeren]")
|
||
sys.exit(1)
|
||
|
||
return args[idx + 1]
|
||
|
||
|
||
# ── main ──────────────────────────────────────────────────────────────────────
|
||
|
||
def run():
|
||
if AUDIT and not DRY_RUN:
|
||
print("--audit is alleen bedoeld voor dry-runs. Laat --uitvoeren weg.")
|
||
sys.exit(1)
|
||
|
||
if DRY_RUN:
|
||
print("DRY RUN — er wordt niets verplaatst.")
|
||
print("Voeg --uitvoeren toe om echt uit te voeren.\n")
|
||
if AUDIT:
|
||
print("Auditmodus: per-bericht dry-runregels worden samengevat per bron en doel.\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)
|
||
|
||
account_name = account_name_from_args()
|
||
cfg = config.load(account_name)
|
||
print(f"Account: {cfg.name} ({cfg.username})\n")
|
||
|
||
print(f"Verbinden met {cfg.imap_host}:{cfg.imap_port} ...")
|
||
session = ImapSession(cfg)
|
||
try:
|
||
session.connect()
|
||
except Exception as e:
|
||
print(f"Verbinding mislukt: {e}")
|
||
sys.exit(1)
|
||
|
||
log = load_log()
|
||
|
||
try:
|
||
stap1_mapverplaatsingen(session, log, DRY_RUN)
|
||
stap2_bronmappen_routing(session, log, DRY_RUN)
|
||
stap3_facturen(session, log, DRY_RUN)
|
||
finally:
|
||
_save_log(log)
|
||
try:
|
||
session.logout()
|
||
except Exception as e:
|
||
print(f"Afmelden mislukt: {e}")
|
||
|
||
print("\nKlaar.")
|
||
if DRY_RUN:
|
||
print("Draai met --uitvoeren om de wijzigingen door te voeren.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run()
|