#!/usr/bin/env python3 """ Kopieert alle mail van een bronaccount naar een doelaccount. Gebruik: python3 kopieer_naar_backup.py --van hans --naar backup Bron en doel worden opgezocht in config.json (aanmaken via python3 config.py --setup). - Mappenstructuur wordt gespiegeld - Vlaggen (gelezen, gemarkeerd, etc.) en ontvangstdatum worden bewaard - Herstart-safe: berichten die al in het doel staan (zelfde Message-ID) worden overgeslagen - Niets wordt verwijderd of gewijzigd in de bronmailbox """ import imaplib import email import email.header import email.utils import re import sys import json import time import config from pathlib import Path from datetime import datetime, timezone LOG_FILE = Path(__file__).parent / "backup_log.json" # Mappen die overgeslagen worden (Trash en Spam zijn optioneel) SKIP_MAPPEN = {"INBOX.Trash", "INBOX.Spam"} # ── helpers ─────────────────────────────────────────────────────────────────── def parse_folder_name(raw: bytes) -> str | None: decoded = raw.decode("utf-8", errors="replace") m = re.match(r'\(.*?\)\s+".*?"\s+(.*)', decoded) if m: return m.group(1).strip().strip('"') return None def parse_flags(flag_str: str) -> list[str]: """Haal standaard IMAP-vlaggen op uit fetch-response.""" return re.findall(r'\\[A-Za-z]+', flag_str) def parse_internaldate(response: bytes) -> str | None: """Haal INTERNALDATE op uit fetch-response voor gebruik in APPEND.""" m = re.search(rb'INTERNALDATE "([^"]+)"', response) if m: try: dt = email.utils.parsedate_to_datetime(m.group(1).decode()) return imaplib.Time2Internaldate(dt.timestamp()) except Exception: pass return None def haal_bestaande_message_ids(mail: imaplib.IMAP4_SSL, folder: str) -> set[str]: """Geef alle Message-IDs terug die al in een doelmap staan.""" status, data = mail.select(f'"{folder}"', readonly=True) if status != "OK" or int(data[0]) == 0: return set() status, data = mail.search(None, "ALL") if status != "OK" or not data[0]: return set() ids = data[0].split() msg_ids: set[str] = set() # Batch ophalen in stukken van 100 for i in range(0, len(ids), 100): batch = b",".join(ids[i:i+100]) status, data = mail.fetch(batch, "(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])") if status != "OK": continue for item in data: if isinstance(item, tuple): msg = email.message_from_bytes(item[1]) mid = (msg.get("Message-ID") or "").strip() if mid: msg_ids.add(mid) return msg_ids # ── kopieerlogica ───────────────────────────────────────────────────────────── def kopieer_map( bron: imaplib.IMAP4_SSL, doel: imaplib.IMAP4_SSL, folder: str, log: dict, ) -> tuple[int, int]: """ Kopieer berichten van bron-folder naar doel-folder. Geeft (gekopieerd, overgeslagen) terug. """ # Selecteer bronmap status, data = bron.select(f'"{folder}"', readonly=True) if status != "OK": print(f" Kan bronmap niet openen: {folder}") return 0, 0 totaal = int(data[0]) if totaal == 0: return 0, 0 # Haal alle bron-UIDs op status, data = bron.search(None, "ALL") if status != "OK" or not data[0]: return 0, 0 ids = data[0].split() # Zorg dat doelmap bestaat doel.create(f'"{folder}"') # negeer fout als al bestaat # Welke Message-IDs staan al in de doelmap? bestaande = haal_bestaande_message_ids(doel, folder) gekopieerd = 0 overgeslagen = 0 for i, uid in enumerate(ids, 1): # Voortgang op één regel print(f"\r {folder}: {i}/{totaal} ({gekopieerd} gekopieerd, {overgeslagen} skip) ", end="", flush=True) # Haal volledige bericht op + vlaggen + datum status, data = bron.fetch( uid, "(FLAGS INTERNALDATE BODY.PEEK[])" ) if status != "OK" or not data: continue # Parse fetch-response raw_response = b"" raw_message = b"" flags = [] internaldate = None for item in data: if isinstance(item, tuple): header_part = item[0].decode("utf-8", errors="replace") raw_message = item[1] flags = parse_flags(header_part) internaldate = parse_internaldate(item[0]) if not raw_message: continue # Controleer Message-ID voor deduplicatie try: msg = email.message_from_bytes(raw_message[:2048]) mid = (msg.get("Message-ID") or "").strip() except Exception: mid = "" if mid and mid in bestaande: overgeslagen += 1 continue # Schrijf naar doelmap flag_str = "(" + " ".join(flags) + ")" if flags else "()" status, _ = doel.append( f'"{folder}"', flag_str, internaldate, raw_message, ) if status == "OK": gekopieerd += 1 if mid: bestaande.add(mid) else: pass # stil doorgaan bij incidentele fout # Even pauzeren elke 50 berichten om server te ontlasten if gekopieerd % 50 == 0 and gekopieerd > 0: time.sleep(0.2) print(f"\r {folder}: {totaal}/{totaal} → {gekopieerd} gekopieerd, {overgeslagen} al aanwezig ") return gekopieerd, overgeslagen # ── log ─────────────────────────────────────────────────────────────────────── def load_log() -> dict: if LOG_FILE.exists(): try: return json.loads(LOG_FILE.read_text(encoding="utf-8")) except Exception: pass return {"voltooide_mappen": [], "totaal_gekopieerd": 0} def save_log(log: dict): LOG_FILE.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8") # ── main ────────────────────────────────────────────────────────────────────── def _account_name(flag: str, default: str) -> str: args = sys.argv[1:] if flag in args: idx = args.index(flag) if idx + 1 < len(args): return args[idx + 1] return default def run(): bron_naam = _account_name("--van", "hans") doel_naam = _account_name("--naar", "backup") bron_cfg = config.load(bron_naam) doel_cfg = config.load(doel_naam) print(f"Bron : {bron_cfg.username}") print(f"Doel : {doel_cfg.username}") print(f"Host : {bron_cfg.imap_host}:{bron_cfg.imap_port}\n") print("Verbinden ...") try: bron = imaplib.IMAP4_SSL(bron_cfg.imap_host, bron_cfg.imap_port) bron.login(bron_cfg.username, bron_cfg.password) print(f" ✓ {bron_cfg.username}") except Exception as e: print(f" ✗ Bron: {e}") sys.exit(1) try: doel = imaplib.IMAP4_SSL(doel_cfg.imap_host, doel_cfg.imap_port) doel.login(doel_cfg.username, doel_cfg.password) print(f" ✓ {doel_cfg.username}") except Exception as e: print(f" ✗ Doel: {e}") bron.logout() sys.exit(1) # Mappenlijst van bron status, raw_folders = bron.list() alle_mappen = [parse_folder_name(f) for f in raw_folders if f] alle_mappen = [f for f in alle_mappen if f and f not in SKIP_MAPPEN] alle_mappen.sort() log = load_log() al_klaar = set(log.get("voltooide_mappen", [])) print(f"\n{len(alle_mappen)} mappen gevonden " f"({len(al_klaar)} al eerder voltooid)\n") totaal_gekopieerd = log.get("totaal_gekopieerd", 0) totaal_overgeslagen = 0 for i, folder in enumerate(alle_mappen, 1): print(f"[{i}/{len(alle_mappen)}] {folder}") if folder in al_klaar: print(f" ✓ al volledig gekopieerd, overgeslagen.\n") continue gekopieerd, overgeslagen = kopieer_map(bron, doel, folder, log) totaal_gekopieerd += gekopieerd totaal_overgeslagen += overgeslagen log["voltooide_mappen"] = list(al_klaar | {folder}) al_klaar.add(folder) log["totaal_gekopieerd"] = totaal_gekopieerd log["laatste_run"] = datetime.now().isoformat() save_log(log) print() bron.logout() doel.logout() print("─" * 50) print(f"Klaar.") print(f" Gekopieerd : {totaal_gekopieerd}") print(f" Al aanwezig : {totaal_overgeslagen}") print(f" Log : {LOG_FILE}") if __name__ == "__main__": run()