Handle IMAP aborts during sorter dry runs

The prompts used to arrive at this change since the previous commit

I have run the previous version of verplaats_bestaand. The results are in verplaats_bestaand.log and I got the following error message: imaplib.IMAP4.abort: command: UID => Server shutting down.

Any special observations that may be relevant for version management for this version. Be brief.

The user-run dry-run reached INBOX.Archief.2022.verzonden before the server closed the IMAP connection. Codex did not run live mailbox scripts; reconnect handling was verified with a fake local IMAP object.
This commit is contained in:
2026-07-04 16:37:41 +02:00
parent f44c363025
commit 8712052332
2 changed files with 181 additions and 18 deletions
+3 -1
View File
@@ -77,6 +77,7 @@ Scripts using shared IMAP folder handling:
- Default mode is dry-run; real moves require `--uitvoeren` and an interactive `JA` confirmation. - Default mode is dry-run; real moves require `--uitvoeren` and an interactive `JA` confirmation.
- `--audit` is available only in dry-run mode. It suppresses per-message dry-run lines and prints planned move counts grouped by source folder and destination folder for step 2 and step 3. - `--audit` is available only in dry-run mode. It suppresses per-message dry-run lines and prints planned move counts grouped by source folder and destination folder for step 2 and step 3.
- IMAP `LIST`, `SELECT`, `UID SEARCH`, and `UID FETCH` aborts are logged and retried once after reconnecting. If reconnect also fails, the script records an `ABORT`/`ERROR` in `verplaats_log.json` instead of printing a Python traceback.
- `MAP_RENAMES` is intentionally empty. - `MAP_RENAMES` is intentionally empty.
- Source folders are selected by shared `list_folders()` plus `mail_routes.is_source_folder()`. - Source folders are selected by shared `list_folders()` plus `mail_routes.is_source_folder()`.
- Step 2 routes all eligible source folders, not just `INBOX`. - Step 2 routes all eligible source folders, not just `INBOX`.
@@ -184,7 +185,8 @@ Earlier route/folder consistency check returned:
Latest live mailbox verification: Latest live mailbox verification:
- Direct read-only IMAP count audit of both `hans` and `backup` accounts after the mirror. - Direct read-only IMAP count audit of both `hans` and `backup` accounts after the mirror.
- No live mailbox script was run while adding the latest auditability changes. - The user ran the previous dry-run sorter version against `backup@australius.nl`. It reached `INBOX.Archief.2022.verzonden` after completing `INBOX.Archief.2022.inkomend`, then the IMAP server closed the connection with `Server shutting down.`. The current code has reconnect/retry handling for that failure mode.
- No live mailbox script was run by Codex while adding the latest reconnect changes.
## What To Do Next ## What To Do Next
+178 -17
View File
@@ -24,6 +24,7 @@ import imaplib
import sys import sys
import json import json
import config import config
import time
from pathlib import Path from pathlib import Path
from collections import Counter from collections import Counter
from imap_utils import list_folders, quote_mailbox from imap_utils import list_folders, quote_mailbox
@@ -67,6 +68,31 @@ def list_source_folders(mail) -> list[str]:
return [folder for folder in list_folders(mail) if is_source_folder(folder)] 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( def log_failure(
log: dict, log: dict,
stap: str, stap: str,
@@ -99,6 +125,102 @@ def print_audit(title: str, by_source: Counter, by_destination: Counter):
print(f" {count:5d} {folder}") print(f" {count:5d} {folder}")
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): def move_message(mail, uid: bytes, src: str, dst_full: str, dry: bool):
"""Kopieer bericht naar dst en markeer als verwijderd in src.""" """Kopieer bericht naar dst en markeer als verwijderd in src."""
if dry: if dry:
@@ -121,7 +243,8 @@ def move_message(mail, uid: bytes, src: str, dst_full: str, dry: bool):
# ── stap 1: vaste mapverplaatsingen ───────────────────────────────────────── # ── stap 1: vaste mapverplaatsingen ─────────────────────────────────────────
def stap1_mapverplaatsingen(mail, log: dict, dry: bool): def stap1_mapverplaatsingen(session: ImapSession, log: dict, dry: bool):
mail = session.mail
print("\n── Stap 1: vaste mapverplaatsingen ──") print("\n── Stap 1: vaste mapverplaatsingen ──")
for oud, nieuw in MAP_RENAMES: for oud, nieuw in MAP_RENAMES:
if nieuw == "__FACTUREN__": if nieuw == "__FACTUREN__":
@@ -132,7 +255,7 @@ def stap1_mapverplaatsingen(mail, log: dict, dry: bool):
continue continue
# Controleer of bronmap bestaat # Controleer of bronmap bestaat
status, data = mail.select(quote_mailbox(oud), readonly=True) status, data = safe_select(session, log, "stap1", oud, readonly=True)
if status != "OK": if status != "OK":
print(f" bestaat niet: {oud}") print(f" bestaat niet: {oud}")
continue continue
@@ -163,9 +286,10 @@ def stap1_mapverplaatsingen(mail, log: dict, dry: bool):
# ── stap 2: per-bericht routing (INBOX) ───────────────────────────────────── # ── stap 2: per-bericht routing (INBOX) ─────────────────────────────────────
def stap2_bronmappen_routing(mail, log: dict, dry: bool): def stap2_bronmappen_routing(session: ImapSession, log: dict, dry: bool):
mail = session.mail
print("\n── Stap 2: bronmappen per-bericht routing ──") print("\n── Stap 2: bronmappen per-bericht routing ──")
bron_mappen = list_source_folders(mail) bron_mappen = safe_list_source_folders(session, log)
print(f" {len(bron_mappen)} bronmappen") print(f" {len(bron_mappen)} bronmappen")
audit_by_source = Counter() audit_by_source = Counter()
@@ -178,7 +302,8 @@ def stap2_bronmappen_routing(mail, log: dict, dry: bool):
al_verwerkt = set(log.get("stap2_verwerkt", [])) al_verwerkt = set(log.get("stap2_verwerkt", []))
for bron in bron_mappen: for bron in bron_mappen:
status, data = mail.select(quote_mailbox(bron), readonly=dry) status, data = safe_select(session, log, "stap2", bron, readonly=dry)
mail = session.mail
if status != "OK": if status != "OK":
print(f" Kan map niet openen: {bron}") print(f" Kan map niet openen: {bron}")
continue continue
@@ -188,7 +313,10 @@ def stap2_bronmappen_routing(mail, log: dict, dry: bool):
continue continue
print(f" {bron}: {n} berichten") print(f" {bron}: {n} berichten")
status, data = mail.uid("SEARCH", "ALL") 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]: if status != "OK" or not data or not data[0]:
continue continue
ids = [uid for uid in data[0].split() if uid] ids = [uid for uid in data[0].split() if uid]
@@ -208,7 +336,18 @@ def stap2_bronmappen_routing(mail, log: dict, dry: bool):
overgeslagen += 1 overgeslagen += 1
continue continue
status, data = mail.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])") status, data = safe_uid(
session,
log,
"stap2",
bron,
uid,
"FETCH",
uid,
"(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])",
readonly=dry,
)
mail = session.mail
if status != "OK" or not data or not data[0]: if status != "OK" or not data or not data[0]:
mislukt += 1 mislukt += 1
log_failure(log, "stap2", bron, uid, None, "fetch_header", status, data) log_failure(log, "stap2", bron, uid, None, "fetch_header", status, data)
@@ -290,7 +429,8 @@ def stap2_bronmappen_routing(mail, log: dict, dry: bool):
# ── stap 3: facturen per kwartaal ──────────────────────────────────────────── # ── stap 3: facturen per kwartaal ────────────────────────────────────────────
def stap3_facturen(mail, log: dict, dry: bool): def stap3_facturen(session: ImapSession, log: dict, dry: bool):
mail = session.mail
print("\n── Stap 3: facturen per kwartaal ──") print("\n── Stap 3: facturen per kwartaal ──")
bron_mappen = [ bron_mappen = [
"INBOX.Facturen - te verwerken", "INBOX.Facturen - te verwerken",
@@ -300,7 +440,8 @@ def stap3_facturen(mail, log: dict, dry: bool):
audit_by_destination = Counter() audit_by_destination = Counter()
for bron in bron_mappen: for bron in bron_mappen:
status, data = mail.select(quote_mailbox(bron), readonly=dry) status, data = safe_select(session, log, "stap3", bron, readonly=dry)
mail = session.mail
if status != "OK": if status != "OK":
continue continue
@@ -310,7 +451,10 @@ def stap3_facturen(mail, log: dict, dry: bool):
continue continue
print(f" {bron}: {n} berichten") print(f" {bron}: {n} berichten")
status, data = mail.uid("SEARCH", "ALL") 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]: if status != "OK" or not data or not data[0]:
continue continue
ids = [uid for uid in data[0].split() if uid] ids = [uid for uid in data[0].split() if uid]
@@ -319,7 +463,18 @@ def stap3_facturen(mail, log: dict, dry: bool):
mislukt = 0 mislukt = 0
for index, uid in enumerate(ids, start=1): for index, uid in enumerate(ids, start=1):
status, data = mail.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (DATE SUBJECT)])") status, data = safe_uid(
session,
log,
"stap3",
bron,
uid,
"FETCH",
uid,
"(BODY.PEEK[HEADER.FIELDS (DATE SUBJECT)])",
readonly=dry,
)
mail = session.mail
if status != "OK" or not data or not data[0]: if status != "OK" or not data or not data[0]:
mislukt += 1 mislukt += 1
log_failure(log, "stap3", bron, uid, None, "fetch_header", status, data) log_failure(log, "stap3", bron, uid, None, "fetch_header", status, data)
@@ -394,20 +549,26 @@ def run():
print(f"Account: {cfg.username}\n") print(f"Account: {cfg.username}\n")
print(f"Verbinden met {cfg.imap_host}:{cfg.imap_port} ...") print(f"Verbinden met {cfg.imap_host}:{cfg.imap_port} ...")
session = ImapSession(cfg)
try: try:
mail = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port) session.connect()
mail.login(cfg.username, cfg.password)
except Exception as e: except Exception as e:
print(f"Verbinding mislukt: {e}") print(f"Verbinding mislukt: {e}")
sys.exit(1) sys.exit(1)
log = load_log() log = load_log()
stap1_mapverplaatsingen(mail, log, DRY_RUN) try:
stap2_bronmappen_routing(mail, log, DRY_RUN) stap1_mapverplaatsingen(session, log, DRY_RUN)
stap3_facturen(mail, 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}")
mail.logout()
print("\nKlaar.") print("\nKlaar.")
if DRY_RUN: if DRY_RUN:
print("Draai met --uitvoeren om de wijzigingen door te voeren.") print("Draai met --uitvoeren om de wijzigingen door te voeren.")