diff --git a/.env b/.env index ef374e1..d0da7dd 100644 --- a/.env +++ b/.env @@ -10,3 +10,7 @@ CALDAV_URL=https://dav.mailbox.org/caldav/Y2FsOi8vMC8zMg CALDAV_USERNAME=minitux@mailbox.org CALDAV_PASSWORD=4711Cayenne64 +CALDAV_TIMEOUT=90 +CALDAV_RETRIES=3 +CALDAV_RETRY_BACKOFF=5 + diff --git a/.env.example b/.env.example index fe1c0db..f68fa2d 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,9 @@ IMAP_MARK_AS_READ=false CALDAV_URL=https://dav.mailbox.org/caldav/IHR_KALENDER_ID CALDAV_USERNAME=ihr-name@mailbox.org -CALDAV_PASSWORD=IHR_PASSWORT_ODER_APP_PASSWORT \ No newline at end of file +CALDAV_PASSWORD=IHR_PASSWORT_ODER_APP_PASSWORT + +Optionale .env-Werte: +CALDAV_TIMEOUT=90 +CALDAV_RETRIES=3 +CALDAV_RETRY_BACKOFF=5 \ No newline at end of file diff --git a/ics_mail_importer.py b/ics_mail_importer.py index 351c151..9af559f 100644 --- a/ics_mail_importer.py +++ b/ics_mail_importer.py @@ -1,15 +1,19 @@ #!/usr/bin/env python3 """ -ics_mail_importer_env_v5.py +ics_mail_importer_env_v6.py --------------------------- ICS-Importer für mailbox.org / CalDAV. -Verbesserungen in v4: -- Liest Mails per BODY.PEEK[] ohne automatisches Setzen von \Seen -- Markiert nur verarbeitete ICS-Mails optional als gelesen -- Behandelt mailbox.org / OX-Konflikte vom Typ CAL-4121 (neuere Version existiert) - als nicht-fatale Konflikte statt als Importfehler -- Protokolliert Konflikte klar als "bereits neuer vorhanden" +Verbesserungen in v6: +- CAL-4121-Konflikte werden als "bereits aktueller Serverstand" behandelt +- Timeout-Fehler beim CalDAV-Upload werden mit Retries und Backoff erneut versucht +- Netzwerk-/Timeout-Probleme werden getrennt von fachlichen Importfehlern protokolliert +- Mails werden nur bei erfolgreicher Verarbeitung oder erkanntem Konflikt als verarbeitet markiert + +Optionale .env-Werte: +- CALDAV_TIMEOUT=90 +- CALDAV_RETRIES=3 +- CALDAV_RETRY_BACKOFF=5 """ import imaplib @@ -17,6 +21,7 @@ import email import logging import hashlib import os +import time from pathlib import Path from datetime import datetime, timezone @@ -53,36 +58,105 @@ def env_bool(name: str, default: str = "false") -> bool: return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"} +def env_int(name: str, default: int) -> int: + value = os.getenv(name) + if value is None or not value.strip(): + return default + return int(value.strip()) + + +def exception_text(exc: Exception) -> str: + return str(exc or "") + + def is_newer_version_conflict(exc: Exception) -> bool: - message = str(exc or "").lower() + message = exception_text(exc).lower() patterns = [ "412 precondition failed", "cal-4121", "newer version of the appointment already exists", "concurrent modification", - "client sequence", "actual sequence", + "client sequence", ] - matches = sum(1 for p in patterns if p in message) - return matches >= 2 + return sum(1 for pattern in patterns if pattern in message) >= 2 + + +def is_timeout_error(exc: Exception) -> bool: + message = exception_text(exc).lower() + timeout_patterns = [ + "read timed out", + "timeout", + "timed out", + "read timeout", + ] + return any(pattern in message for pattern in timeout_patterns) + + +def add_event_with_retries(calendar, ical_text: str, summary: str, uid: str, sequence, timeout: int, retries: int, backoff: int): + last_exception = None + + for attempt in range(1, retries + 1): + try: + calendar.add_event(ical_text) + log.info(f" ✓ Termin importiert: {summary} [{uid[:20]}]") + return "imported" + except Exception as e: + last_exception = e + exc_name = type(e).__name__ + exc_message = exception_text(e) + log.info(f" Exception-Typ beim Import: {exc_name}") + + if is_newer_version_conflict(e): + log.info( + f" ↷ Konflikt ignoriert: Neuere Version existiert bereits: {summary} " + f"[{uid[:20]}], SEQUENCE={sequence}" + ) + return "conflict" + + if is_timeout_error(e): + if attempt < retries: + wait_seconds = backoff * attempt + log.warning( + f" ⏳ Timeout beim Import von {uid[:20]} (Versuch {attempt}/{retries}, Timeout={timeout}s). " + f"Neuer Versuch in {wait_seconds}s ..." + ) + time.sleep(wait_seconds) + continue + log.warning( + f" ✗ Timeout beim Import von {uid[:20]} nach {retries} Versuchen: {exc_message}" + ) + return "timeout" + + log.warning(f" ✗ Fehler beim Import von {uid[:20]}: {exc_message}") + return "error" + + if last_exception is not None: + raise last_exception + return "error" def import_ics_to_caldav(ics_bytes: bytes, uid_hash: str): caldav_url = os.getenv("CALDAV_URL") username = os.getenv("CALDAV_USERNAME") password = os.getenv("CALDAV_PASSWORD") + timeout = env_int("CALDAV_TIMEOUT", 90) + retries = env_int("CALDAV_RETRIES", 3) + backoff = env_int("CALDAV_RETRY_BACKOFF", 5) if not caldav_url or not username or not password: raise RuntimeError( "CALDAV_URL, CALDAV_USERNAME oder CALDAV_PASSWORD fehlt in .env" ) - client = caldav.DAVClient(url=caldav_url, username=username, password=password) + client = caldav.DAVClient(url=caldav_url, username=username, password=password, timeout=timeout) calendar = client.calendar(url=caldav_url) cal = Calendar.from_ical(ics_bytes) imported = 0 conflicts = 0 + timeouts = 0 + errors = 0 for component in cal.walk(): if component.name != "VEVENT": @@ -93,25 +167,30 @@ def import_ics_to_caldav(ics_bytes: bytes, uid_hash: str): single_cal.add("version", "2.0") single_cal.add_component(component) - uid = str(component.get("uid", uid_hash + f"-{imported + conflicts}")) - summary = component.get("summary", "(kein Titel)") + uid = str(component.get("uid", uid_hash + f"-{imported + conflicts + timeouts + errors}")) + summary = str(component.get("summary", "(kein Titel)")) sequence = component.get("sequence", "(keine sequence)") + result = add_event_with_retries( + calendar, + single_cal.to_ical().decode("utf-8"), + summary, + uid, + sequence, + timeout, + retries, + backoff, + ) - try: - calendar.add_event(single_cal.to_ical().decode("utf-8")) - log.info(f" ✓ Termin importiert: {summary} [{uid[:20]}]") + if result == "imported": imported += 1 - except Exception as e: - if is_newer_version_conflict(e): - log.info( - f" ↷ Konflikt ignoriert: Neuere Version existiert bereits: {summary} " - f"[{uid[:20]}], SEQUENCE={sequence}" - ) - conflicts += 1 - else: - log.warning(f" ✗ Fehler beim Import von {uid[:20]}: {e}") + elif result == "conflict": + conflicts += 1 + elif result == "timeout": + timeouts += 1 + else: + errors += 1 - return imported, conflicts + return imported, conflicts, timeouts, errors def process_mailbox(): @@ -143,6 +222,8 @@ def process_mailbox(): imported_hashes = load_seen_uids() total_imported = 0 total_conflicts = 0 + total_timeouts = 0 + total_errors = 0 processed_mail_count = 0 for msg_uid in message_uids: @@ -181,9 +262,11 @@ def process_mailbox(): log.info(f" ICS-Anhang gefunden: {filename!r} ({uid_hash[:12]}…)") try: - imported_count, conflict_count = import_ics_to_caldav(ics_bytes, uid_hash) + imported_count, conflict_count, timeout_count, error_count = import_ics_to_caldav(ics_bytes, uid_hash) total_imported += imported_count total_conflicts += conflict_count + total_timeouts += timeout_count + total_errors += error_count if imported_count > 0 or conflict_count > 0: save_uid(uid_hash) @@ -191,7 +274,7 @@ def process_mailbox(): mail_processed = True else: log.warning( - f" Kein VEVENT importiert oder als Konflikt erkannt für Anhang {filename!r}; Mail bleibt ungelesen." + f" Kein VEVENT erfolgreich verarbeitet für Anhang {filename!r}; Mail bleibt ungelesen." ) except Exception as e: log.warning( @@ -215,6 +298,8 @@ def process_mailbox(): log.info(f"Fertig. {total_imported} Termin(e) importiert.") log.info(f"{total_conflicts} Konflikt(e) als bereits aktueller Serverstand erkannt.") + log.info(f"{total_timeouts} Timeout-Fall/Fälle beim CalDAV-Upload.") + log.info(f"{total_errors} sonstige Fehler beim CalDAV-Upload.") log.info(f"{processed_mail_count} Mail(s) als gelesen markiert.")