f44c363025
The prompts used to arrive at this change since the previous commit Read restart_prompt.md first, then continue from there. Any special observations that may be relevant for version management for this version. Be brief. No mailbox-affecting scripts were run by Codex. A generated dry-run transcript appeared locally as verplaats_bestaand.log and is now ignored via *.log.
342 lines
11 KiB
Python
342 lines
11 KiB
Python
#!/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
|
|
from imap_utils import list_folders, quote_mailbox
|
|
|
|
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_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(quote_mailbox(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, int]:
|
|
"""
|
|
Kopieer berichten van bron-folder naar doel-folder.
|
|
Geeft (gekopieerd, overgeslagen, mislukt) terug.
|
|
"""
|
|
# Selecteer bronmap
|
|
status, data = bron.select(quote_mailbox(folder), readonly=True)
|
|
if status != "OK":
|
|
print(f" Kan bronmap niet openen: {folder}")
|
|
log.setdefault("fouten", []).append({
|
|
"folder": folder,
|
|
"actie": "select_bron",
|
|
"status": status,
|
|
"response": repr(data),
|
|
})
|
|
return 0, 0, 1
|
|
|
|
totaal = int(data[0])
|
|
if totaal == 0:
|
|
return 0, 0, 0
|
|
|
|
# Haal alle bron-UIDs op
|
|
status, data = bron.search(None, "ALL")
|
|
if status != "OK" or not data[0]:
|
|
log.setdefault("fouten", []).append({
|
|
"folder": folder,
|
|
"actie": "search_bron",
|
|
"status": status,
|
|
"response": repr(data),
|
|
})
|
|
return 0, 0, 1
|
|
ids = data[0].split()
|
|
|
|
# Zorg dat doelmap bestaat
|
|
create_status, create_data = doel.create(quote_mailbox(folder))
|
|
if create_status not in {"OK", "NO"}:
|
|
log.setdefault("fouten", []).append({
|
|
"folder": folder,
|
|
"actie": "create_doel",
|
|
"status": create_status,
|
|
"response": repr(create_data),
|
|
})
|
|
return 0, 0, 1
|
|
select_status, select_data = doel.select(quote_mailbox(folder), readonly=True)
|
|
if select_status != "OK":
|
|
log.setdefault("fouten", []).append({
|
|
"folder": folder,
|
|
"actie": "select_doel_na_create",
|
|
"status": select_status,
|
|
"response": repr(select_data),
|
|
})
|
|
return 0, 0, 1
|
|
|
|
# Welke Message-IDs staan al in de doelmap?
|
|
bestaande = haal_bestaande_message_ids(doel, folder)
|
|
|
|
gekopieerd = 0
|
|
overgeslagen = 0
|
|
mislukt = 0
|
|
|
|
for i, uid in enumerate(ids, 1):
|
|
# Voortgang op één regel
|
|
print(f"\r {folder}: {i}/{totaal} ({gekopieerd} gekopieerd, {overgeslagen} skip, {mislukt} fout) ",
|
|
end="", flush=True)
|
|
|
|
# Haal volledige bericht op + vlaggen + datum
|
|
status, data = bron.fetch(
|
|
uid, "(FLAGS INTERNALDATE BODY.PEEK[])"
|
|
)
|
|
if status != "OK" or not data:
|
|
mislukt += 1
|
|
log.setdefault("fouten", []).append({
|
|
"folder": folder,
|
|
"uid": uid.decode(errors="replace"),
|
|
"actie": "fetch_bron",
|
|
"status": status,
|
|
"response": repr(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:
|
|
mislukt += 1
|
|
log.setdefault("fouten", []).append({
|
|
"folder": folder,
|
|
"uid": uid.decode(errors="replace"),
|
|
"actie": "fetch_lege_body",
|
|
"status": status,
|
|
"response": repr(data),
|
|
})
|
|
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, append_data = doel.append(
|
|
quote_mailbox(folder),
|
|
flag_str,
|
|
internaldate,
|
|
raw_message,
|
|
)
|
|
if status == "OK":
|
|
gekopieerd += 1
|
|
if mid:
|
|
bestaande.add(mid)
|
|
else:
|
|
mislukt += 1
|
|
log.setdefault("fouten", []).append({
|
|
"folder": folder,
|
|
"uid": uid.decode(errors="replace"),
|
|
"actie": "append_doel",
|
|
"message_id": mid,
|
|
"status": status,
|
|
"response": repr(append_data),
|
|
})
|
|
|
|
# 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, {mislukt} fout ")
|
|
return gekopieerd, overgeslagen, mislukt
|
|
|
|
|
|
# ── 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
|
|
alle_mappen = [folder for folder in list_folders(bron) if folder 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
|
|
totaal_mislukt = 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, mislukt = kopieer_map(bron, doel, folder, log)
|
|
totaal_gekopieerd += gekopieerd
|
|
totaal_overgeslagen += overgeslagen
|
|
totaal_mislukt += mislukt
|
|
|
|
if mislukt == 0:
|
|
log["voltooide_mappen"] = list(al_klaar | {folder})
|
|
al_klaar.add(folder)
|
|
else:
|
|
log["voltooide_mappen"] = list(al_klaar)
|
|
print(f" ! niet als voltooid gemarkeerd door {mislukt} fout(en)")
|
|
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" Fouten : {totaal_mislukt}")
|
|
print(f" Log : {LOG_FILE}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run()
|