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)
This commit is contained in:
+138
-73
@@ -1,11 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
ics_mail_importer_env.py
|
ics_mail_importer_env_v4.py
|
||||||
------------------------
|
---------------------------
|
||||||
Sucht in einem mailbox.org IMAP-Postfach nach E-Mails mit .ics-Anhängen
|
ICS-Importer für mailbox.org / CalDAV.
|
||||||
und importiert die enthaltenen Termine automatisch in einen CalDAV-Kalender.
|
|
||||||
|
|
||||||
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
|
import imaplib
|
||||||
@@ -45,59 +49,20 @@ def save_uid(uid: str):
|
|||||||
f.write(uid + "\n")
|
f.write(uid + "\n")
|
||||||
|
|
||||||
|
|
||||||
def fetch_ics_attachments():
|
def env_bool(name: str, default: str = "false") -> bool:
|
||||||
host = os.getenv("IMAP_HOST", "imap.mailbox.org")
|
return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
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} ...")
|
def is_newer_version_conflict(exc: Exception) -> bool:
|
||||||
conn = imaplib.IMAP4_SSL(host, port)
|
message = str(exc)
|
||||||
conn.login(username, password)
|
return (
|
||||||
conn.select(folder)
|
"412 Precondition Failed" in message
|
||||||
|
and "CAL-4121" in message
|
||||||
unseen_only = os.getenv("IMAP_UNSEEN_ONLY", "true").lower() == "true"
|
and "newer version of the appointment already exists" in message
|
||||||
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 import_to_caldav(ics_bytes: bytes, uid_hash: str):
|
def import_ics_to_caldav(ics_bytes: bytes, uid_hash: str):
|
||||||
caldav_url = os.getenv("CALDAV_URL")
|
caldav_url = os.getenv("CALDAV_URL")
|
||||||
username = os.getenv("CALDAV_USERNAME")
|
username = os.getenv("CALDAV_USERNAME")
|
||||||
password = os.getenv("CALDAV_PASSWORD")
|
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)
|
client = caldav.DAVClient(url=caldav_url, username=username, password=password)
|
||||||
calendar = client.calendar(url=caldav_url)
|
calendar = client.calendar(url=caldav_url)
|
||||||
|
|
||||||
cal = Calendar.from_ical(ics_bytes)
|
cal = Calendar.from_ical(ics_bytes)
|
||||||
|
|
||||||
imported = 0
|
imported = 0
|
||||||
|
conflicts = 0
|
||||||
|
|
||||||
for component in cal.walk():
|
for component in cal.walk():
|
||||||
if component.name != "VEVENT":
|
if component.name != "VEVENT":
|
||||||
continue
|
continue
|
||||||
@@ -122,36 +88,135 @@ def import_to_caldav(ics_bytes: bytes, uid_hash: str):
|
|||||||
single_cal.add("version", "2.0")
|
single_cal.add("version", "2.0")
|
||||||
single_cal.add_component(component)
|
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:
|
try:
|
||||||
calendar.add_event(single_cal.to_ical().decode("utf-8"))
|
calendar.add_event(single_cal.to_ical().decode("utf-8"))
|
||||||
log.info(
|
log.info(f" ✓ Termin importiert: {summary} [{uid[:20]}]")
|
||||||
f" ✓ Termin importiert: {component.get('summary', '(kein Titel)')} [{uid[:20]}]"
|
|
||||||
)
|
|
||||||
imported += 1
|
imported += 1
|
||||||
except Exception as e:
|
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}")
|
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():
|
def main():
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
log.info(f"ICS-Importer gestartet ({datetime.now(timezone.utc).isoformat()})")
|
log.info(f"ICS-Importer gestartet ({datetime.now(timezone.utc).isoformat()})")
|
||||||
|
process_mailbox()
|
||||||
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.")
|
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user