From 52c3c32c54e91b2e703e316df2bd1c55309ea5e8 Mon Sep 17 00:00:00 2001 From: Payer Hans-Christian Date: Mon, 1 Jun 2026 10:02:36 +0200 Subject: [PATCH] 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) --- ics_mail_importer.py | 221 ++++++++++++++++++++++++++++--------------- 1 file changed, 143 insertions(+), 78 deletions(-) diff --git a/ics_mail_importer.py b/ics_mail_importer.py index efe028a..0db10ce 100644 --- a/ics_mail_importer.py +++ b/ics_mail_importer.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 """ -ics_mail_importer_env.py ------------------------- -Sucht in einem mailbox.org IMAP-Postfach nach E-Mails mit .ics-Anhängen -und importiert die enthaltenen Termine automatisch in einen CalDAV-Kalender. +ics_mail_importer_env_v4.py +--------------------------- +ICS-Importer für mailbox.org / CalDAV. -Konfiguration über .env-Datei mit python-dotenv. +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" """ import imaplib @@ -45,59 +49,20 @@ def save_uid(uid: str): f.write(uid + "\n") -def fetch_ics_attachments(): - host = os.getenv("IMAP_HOST", "imap.mailbox.org") - port = int(os.getenv("IMAP_PORT", "993")) - username = os.getenv("IMAP_USERNAME") - password = os.getenv("IMAP_PASSWORD") - folder = os.getenv("IMAP_FOLDER", "INBOX") - - if not username or not password: - raise RuntimeError("IMAP_USERNAME oder IMAP_PASSWORD fehlt in .env") - - log.info(f"Verbinde mit IMAP {host}:{port} als {username} ...") - conn = imaplib.IMAP4_SSL(host, port) - conn.login(username, password) - conn.select(folder) - - unseen_only = os.getenv("IMAP_UNSEEN_ONLY", "true").lower() == "true" - criteria = "(UNSEEN)" if unseen_only else "ALL" - _, msg_ids = conn.search(None, criteria) - - found = [] - for mid in msg_ids[0].split(): - _, data = conn.fetch(mid, "(RFC822)") - raw = data[0][1] - msg = email.message_from_bytes(raw) - - for part in msg.walk(): - ct = part.get_content_type() - fn = part.get_filename() or "" - is_ics = ct in ("text/calendar", "application/ics") or fn.lower().endswith( - ".ics" - ) - if not is_ics: - continue - - ics_bytes = part.get_payload(decode=True) - if not ics_bytes: - continue - - uid_hash = hashlib.sha256(ics_bytes).hexdigest() - log.info(f" ics-Anhang gefunden: {fn!r} (Hash {uid_hash[:12]}…)") - found.append((uid_hash, ics_bytes)) - - mark_read = os.getenv("IMAP_MARK_AS_READ", "false").lower() == "true" - if mark_read: - conn.store(mid, "+FLAGS", "\\Seen") - - conn.close() - conn.logout() - log.info(f" {len(found)} .ics-Anhang/-Anhänge gefunden.") - return found +def env_bool(name: str, default: str = "false") -> bool: + return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"} -def import_to_caldav(ics_bytes: bytes, uid_hash: str): +def is_newer_version_conflict(exc: Exception) -> bool: + message = str(exc) + return ( + "412 Precondition Failed" in message + and "CAL-4121" in message + and "newer version of the appointment already exists" in message + ) + + +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") @@ -109,10 +74,11 @@ def import_to_caldav(ics_bytes: bytes, uid_hash: str): client = caldav.DAVClient(url=caldav_url, username=username, password=password) calendar = client.calendar(url=caldav_url) - cal = Calendar.from_ical(ics_bytes) imported = 0 + conflicts = 0 + for component in cal.walk(): if component.name != "VEVENT": continue @@ -122,38 +88,137 @@ def import_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}")) + uid = str(component.get("uid", uid_hash + f"-{imported + conflicts}")) + summary = component.get("summary", "(kein Titel)") + sequence = component.get("sequence", "(keine sequence)") + try: calendar.add_event(single_cal.to_ical().decode("utf-8")) - log.info( - f" ✓ Termin importiert: {component.get('summary', '(kein Titel)')} [{uid[:20]}]" - ) + log.info(f" ✓ Termin importiert: {summary} [{uid[:20]}]") imported += 1 except Exception as e: - log.warning(f" ✗ Fehler beim Import von {uid[:20]}: {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}") - return imported + return imported, conflicts + + +def process_mailbox(): + host = os.getenv("IMAP_HOST", "imap.mailbox.org") + port = int(os.getenv("IMAP_PORT", "993")) + username = os.getenv("IMAP_USERNAME") + password = os.getenv("IMAP_PASSWORD") + folder = os.getenv("IMAP_FOLDER", "INBOX") + + if not username or not password: + raise RuntimeError("IMAP_USERNAME oder IMAP_PASSWORD fehlt in .env") + + unseen_only = env_bool("IMAP_UNSEEN_ONLY", "true") + mark_as_read = env_bool("IMAP_MARK_AS_READ", "false") + + log.info(f"Verbinde mit IMAP {host}:{port} als {username} ...") + conn = imaplib.IMAP4_SSL(host, port) + conn.login(username, password) + + conn.select(folder) + criteria = "UNSEEN" if unseen_only else "ALL" + typ, data = conn.uid("search", None, criteria) + if typ != "OK": + raise RuntimeError("IMAP-Suche fehlgeschlagen") + + message_uids = data[0].split() + log.info(f"{len(message_uids)} Nachricht(en) zur Prüfung gefunden.") + + imported_hashes = load_seen_uids() + total_imported = 0 + total_conflicts = 0 + processed_mail_count = 0 + + for msg_uid in message_uids: + typ, fetched = conn.uid("fetch", msg_uid, "(BODY.PEEK[])") + if typ != "OK" or not fetched or not fetched[0]: + log.warning(f"Konnte Nachricht UID {msg_uid.decode()} nicht abrufen.") + continue + + raw = fetched[0][1] + msg = email.message_from_bytes(raw) + + mail_had_ics = False + mail_processed = False + subject = msg.get("Subject", "(ohne Betreff)") + log.info(f"Prüfe Mail UID {msg_uid.decode()} – Betreff: {subject}") + + for part in msg.walk(): + content_type = part.get_content_type() + filename = part.get_filename() or "" + is_ics = content_type in ("text/calendar", "application/ics") or filename.lower().endswith(".ics") + if not is_ics: + continue + + ics_bytes = part.get_payload(decode=True) + if not ics_bytes: + continue + + mail_had_ics = True + uid_hash = hashlib.sha256(ics_bytes).hexdigest() + + if uid_hash in imported_hashes: + log.info(f" Überspringe bereits verarbeiteten ICS-Anhang {filename!r} ({uid_hash[:12]}…)") + mail_processed = True + continue + + log.info(f" ICS-Anhang gefunden: {filename!r} ({uid_hash[:12]}…)") + + try: + imported_count, conflict_count = import_ics_to_caldav(ics_bytes, uid_hash) + total_imported += imported_count + total_conflicts += conflict_count + + if imported_count > 0 or conflict_count > 0: + save_uid(uid_hash) + imported_hashes.add(uid_hash) + mail_processed = True + else: + log.warning( + f" Kein VEVENT importiert oder als Konflikt erkannt für Anhang {filename!r}; Mail bleibt ungelesen." + ) + except Exception as e: + log.warning( + f" Fehler beim Verarbeiten von {filename!r}: {e}; Mail bleibt ungelesen." + ) + + if mark_as_read and mail_processed: + store_typ, _ = conn.uid("store", msg_uid, "+FLAGS", "(\\Seen)") + if store_typ == "OK": + processed_mail_count += 1 + log.info(f"Mail UID {msg_uid.decode()} erfolgreich verarbeitet und als gelesen markiert.") + else: + log.warning( + f"Mail UID {msg_uid.decode()} wurde verarbeitet, konnte aber nicht als gelesen markiert werden." + ) + elif mark_as_read and mail_had_ics and not mail_processed: + log.info(f"Mail UID {msg_uid.decode()} hatte ICS-Anhang, aber keine erfolgreiche Verarbeitung; bleibt ungelesen.") + + conn.close() + conn.logout() + + log.info(f"Fertig. {total_imported} Termin(e) importiert.") + log.info(f"{total_conflicts} Konflikt(e) als bereits aktueller Serverstand erkannt.") + log.info(f"{processed_mail_count} Mail(s) als gelesen markiert.") def main(): log.info("=" * 60) log.info(f"ICS-Importer gestartet ({datetime.now(timezone.utc).isoformat()})") - - seen_uids = load_seen_uids() - attachments = fetch_ics_attachments() - - total_imported = 0 - for uid_hash, ics_bytes in attachments: - if uid_hash in seen_uids: - log.info(f" Überspringe bereits importierten Anhang {uid_hash[:12]}…") - continue - n = import_to_caldav(ics_bytes, uid_hash) - total_imported += n - save_uid(uid_hash) - - log.info(f"Fertig. {total_imported} Termine insgesamt importiert.") + process_mailbox() log.info("=" * 60) if __name__ == "__main__": - main() \ No newline at end of file + main()