#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ caldav_crud.py — Sichere CalDAV CRUD-Operationen für mailbox.org ================================================================= Authentifizierung: python-keyring (bevorzugt) → .env-Datei → Umgebungsvariablen Abhängigkeiten: pip install caldav python-dotenv keyring vobject Verwendung: chmod +x caldav_crud.py ./caldav_crud.py --help ./caldav_crud.py list ./caldav_crud.py create --summary "Meeting" --start 2026-04-24T10:00 --end 2026-04-24T11:00 ./caldav_crud.py read --uid ./caldav_crud.py update --uid --summary "Neuer Titel" ./caldav_crud.py delete --uid ./caldav_crud.py store-password # Passwort einmalig in keyring speichern ./caldav_crud.py delete-password # Passwort aus keyring entfernen """ import argparse import logging import os import sys import uuid from datetime import datetime, timezone from pathlib import Path # ── Drittanbieter-Importe mit Fehlerhinweis ────────────────────────────────── try: import caldav except ImportError: sys.exit("Fehlend: caldav → pip install caldav") try: from dotenv import load_dotenv except ImportError: sys.exit("Fehlend: python-dotenv → pip install python-dotenv") try: import keyring import keyring.errors HAS_KEYRING = True except ImportError: HAS_KEYRING = False print("Hinweis: keyring nicht verfügbar, falle zurück auf .env / Umgebungsvariablen.", file=sys.stderr) try: import vobject except ImportError: sys.exit("Fehlend: vobject → pip install vobject") try: import pytz except ImportError: sys.exit("Fehlend: pytz → pip install pytz") # ── Konstanten ─────────────────────────────────────────────────────────────── KEYRING_SERVICE = "mailbox_caldav" KEYRING_USERNAME = "caldav_password" ENV_FILE_PATHS = [ Path.home() / ".openclaw" / ".env", # OpenClaw-Standardpfad Path(".env"), # Aktuelles Verzeichnis (Fallback) ] DEFAULT_CALENDAR_NAME = None # None = ersten verfügbaren Kalender nutzen # ── Logging ────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.WARNING, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) log = logging.getLogger(__name__) # ═══════════════════════════════════════════════════════════════════════════════ # Authentifizierung & Konfiguration # ═══════════════════════════════════════════════════════════════════════════════ def load_env() -> None: """Lädt .env-Datei aus bekannten Pfaden (erste gefundene gewinnt).""" for path in ENV_FILE_PATHS: if path.exists(): load_dotenv(dotenv_path=path, override=False) log.info(f".env geladen von: {path}") return log.info("Keine .env-Datei gefunden — verwende reine Umgebungsvariablen.") def get_password(username: str) -> str: """ Passwort-Abruf-Reihenfolge (sicherste zuerst): 1. python-keyring (systemischer Schlüsselbund / libsecret) 2. Umgebungsvariable MAILBOX_DAV_PASSWORD (aus .env oder Shell) 3. Interaktive Eingabe (Fallback, kein Echo) """ # 1. Keyring if HAS_KEYRING: try: password = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME) if password: log.info("Passwort aus keyring geladen.") return password except keyring.errors.KeyringError as exc: log.warning(f"Keyring-Fehler: {exc} — falle zurück auf .env") # 2. Umgebungsvariable password = os.getenv("MAILBOX_DAV_PASSWORD") if password: log.info("Passwort aus Umgebungsvariable MAILBOX_DAV_PASSWORD geladen.") return password # 3. Interaktive Eingabe import getpass print(f"Kein gespeichertes Passwort für '{username}' gefunden.") print("Tipp: Einmalig speichern mit: ./caldav_crud.py store-password") return getpass.getpass(f"App-Passwort für {username}: ") def get_config() -> dict: """ Liest Konfiguration aus Umgebungsvariablen. Priorität: keyring-Passwort > MAILBOX_DAV_PASSWORD > interaktiv. """ load_env() url = os.getenv("MAILBOX_DAV_URL", "https://dav.mailbox.org") username = os.getenv("MAILBOX_DAV_USERNAME", "") calendar = os.getenv("MAILBOX_DAV_CALENDAR", "") # optional: Kalenderbezeichnung if not username: sys.exit( "Fehler: MAILBOX_DAV_USERNAME ist nicht gesetzt.\n" " → Setze ihn in ~/.openclaw/.env oder als Umgebungsvariable." ) password = get_password(username) return { "url": url, "username": username, "password": password, "calendar": calendar or None, } # ═══════════════════════════════════════════════════════════════════════════════ # CalDAV-Verbindung # ═══════════════════════════════════════════════════════════════════════════════ def connect(cfg: dict) -> caldav.DAVClient: """Baut eine authentifizierte DAV-Verbindung auf.""" client = caldav.DAVClient( url=cfg["url"], username=cfg["username"], password=cfg["password"], ) # Verbindungstest try: client.principal() log.info(f"Verbunden mit {cfg['url']} als {cfg['username']}") except Exception as exc: sys.exit(f"Verbindungsfehler: {exc}") return client def get_calendar(client: caldav.DAVClient, calendar_name: str | None = None) -> caldav.Calendar: """ Gibt den gewünschten Kalender zurück. Wenn calendar_name=None → ersten verfügbaren Kalender verwenden. """ principal = client.principal() calendars = principal.calendars() if not calendars: sys.exit("Keine Kalender auf dem Server gefunden.") if calendar_name: for cal in calendars: if cal.name and cal.name.lower() == calendar_name.lower(): return cal available = ", ".join(c.name or "" for c in calendars) sys.exit( f"Kalender '{calendar_name}' nicht gefunden.\n" f"Verfügbar: {available}" ) return calendars[0] # ═══════════════════════════════════════════════════════════════════════════════ # iCalendar-Hilfsfunktionen # ═══════════════════════════════════════════════════════════════════════════════ def _to_vobject_dt(dt: datetime) -> datetime: """ vobject kann stdlib timezone.utc nicht erkennen → in pytz.UTC konvertieren. Alle anderen tz-Infos werden ebenfalls in pytz-Objekte überführt. """ if dt.tzinfo is None: return dt.replace(tzinfo=pytz.UTC) if dt.tzinfo is timezone.utc or str(dt.tzinfo) == "UTC": return dt.replace(tzinfo=pytz.UTC) # Versuche, den IANA-Namen zu ermitteln (bei zoneinfo / pytz-Objekten) try: iana_name = dt.tzname() if iana_name and iana_name != "UTC": return dt.astimezone(pytz.timezone(iana_name)) except Exception: pass return dt.replace(tzinfo=pytz.UTC) def make_vcalendar( uid: str, summary: str, start: datetime, end: datetime, description: str = "", location: str = "", ) -> str: """Erstellt einen validen VCALENDAR-String (RFC 5545).""" cal = vobject.iCalendar() cal.add("prodid").value = "-//caldav_crud.py//mailbox.org//DE" cal.add("version").value = "2.0" event = cal.add("vevent") event.add("uid").value = uid event.add("summary").value = summary event.add("dtstart").value = _to_vobject_dt(start) event.add("dtend").value = _to_vobject_dt(end) event.add("dtstamp").value = _to_vobject_dt(datetime.now(tz=timezone.utc)) if description: event.add("description").value = description if location: event.add("location").value = location return cal.serialize() def parse_datetime(value: str) -> datetime: """ Parst ISO-8601-Datetime-Strings flexibel. Unterstützt: 2026-04-24T10:00, 2026-04-24T10:00:00, 2026-04-24T10:00+02:00 """ formats = [ "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d", ] for fmt in formats: try: dt = datetime.strptime(value, fmt) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt except ValueError: continue sys.exit( f"Ungültiges Datum/Uhrzeit-Format: '{value}'\n" f"Erwartet z.B.: 2026-04-24T10:00 oder 2026-04-24T10:00:00+02:00" ) def extract_event_info(event: caldav.Event) -> dict: """Extrahiert lesbare Felder aus einem CalDAV-Event-Objekt.""" try: vcal = vobject.readOne(event.data) vevent = vcal.vevent return { "uid": getattr(vevent, "uid", None) and vevent.uid.value, "summary": getattr(vevent, "summary", None) and vevent.summary.value, "dtstart": getattr(vevent, "dtstart", None) and str(vevent.dtstart.value), "dtend": getattr(vevent, "dtend", None) and str(vevent.dtend.value), "description": getattr(vevent, "description", None) and vevent.description.value, "location": getattr(vevent, "location", None) and vevent.location.value, } except Exception as exc: log.warning(f"Event-Parsing-Fehler: {exc}") return {"raw": event.data} # ═══════════════════════════════════════════════════════════════════════════════ # CRUD-Operationen # ═══════════════════════════════════════════════════════════════════════════════ def cmd_list(args, cal: caldav.Calendar) -> None: """READ-ALL: Alle Events auflisten.""" results = cal.events() if not results: print("Keine Events im Kalender gefunden.") return print(f"\n{'UID':<38} {'Start':<22} Titel") print("-" * 90) for ev in results: info = extract_event_info(ev) uid = (info.get("uid") or "")[:36] start = (info.get("dtstart") or "")[:22] summary = (info.get("summary") or "")[:50] print(f"{uid:<38} {start:<22} {summary}") def cmd_create(args, cal: caldav.Calendar) -> None: """CREATE: Neues Event anlegen.""" if not args.summary: sys.exit("Fehler: --summary ist Pflichtfeld für 'create'") if not args.start: sys.exit("Fehler: --start ist Pflichtfeld für 'create'") if not args.end: sys.exit("Fehler: --end ist Pflichtfeld für 'create'") event_uid = args.uid or str(uuid.uuid4()) start_dt = parse_datetime(args.start) end_dt = parse_datetime(args.end) ical_data = make_vcalendar( uid=event_uid, summary=args.summary, start=start_dt, end=end_dt, description=args.description or "", location=args.location or "", ) cal.save_event(ical_data) print(f"✓ Event erstellt UID: {event_uid}") def cmd_read(args, cal: caldav.Calendar) -> None: """READ: Einzelnes Event anzeigen.""" if not args.uid: sys.exit("Fehler: --uid ist Pflichtfeld für 'read'") event = _find_event(cal, args.uid) info = extract_event_info(event) print(f"\n── Event ──────────────────────────────────") for key, val in info.items(): if val: print(f" {key:<12}: {val}") print() def cmd_update(args, cal: caldav.Calendar) -> None: """UPDATE: Vorhandenes Event bearbeiten.""" if not args.uid: sys.exit("Fehler: --uid ist Pflichtfeld für 'update'") event = _find_event(cal, args.uid) vcal = vobject.readOne(event.data) vevent = vcal.vevent if args.summary: vevent.summary.value = args.summary if args.start: vevent.dtstart.value = _to_vobject_dt(parse_datetime(args.start)) if args.end: vevent.dtend.value = _to_vobject_dt(parse_datetime(args.end)) if args.description: if hasattr(vevent, "description"): vevent.description.value = args.description else: vevent.add("description").value = args.description if args.location: if hasattr(vevent, "location"): vevent.location.value = args.location else: vevent.add("location").value = args.location # DTSTAMP aktualisieren vevent.dtstamp.value = _to_vobject_dt(datetime.now(tz=timezone.utc)) event.data = vcal.serialize() event.save() print(f"✓ Event aktualisiert UID: {args.uid}") def cmd_delete(args, cal: caldav.Calendar) -> None: """DELETE: Event löschen.""" if not args.uid: sys.exit("Fehler: --uid ist Pflichtfeld für 'delete'") event = _find_event(cal, args.uid) if not args.force: info = extract_event_info(event) print(f"Event zum Löschen: {info.get('summary', '')} ({args.uid})") confirm = input("Wirklich löschen? [j/N] ").strip().lower() if confirm not in ("j", "ja", "y", "yes"): print("Abgebrochen.") return event.delete() print(f"✓ Event gelöscht UID: {args.uid}") def _find_event(cal: caldav.Calendar, uid: str) -> caldav.Event: """Sucht ein Event anhand seiner UID — wirft SystemExit wenn nicht gefunden.""" results = cal.events() for ev in results: info = extract_event_info(ev) if info.get("uid") == uid: return ev # Fallback: direkte URL-basierte Suche via search try: events = cal.search(uid=uid, event=True) if events: return events[0] except Exception: pass sys.exit(f"Event mit UID '{uid}' nicht gefunden.") # ═══════════════════════════════════════════════════════════════════════════════ # Keyring-Verwaltung # ═══════════════════════════════════════════════════════════════════════════════ def cmd_store_password(args) -> None: """Speichert das App-Passwort einmalig im System-Schlüsselbund.""" if not HAS_KEYRING: sys.exit("Fehler: python-keyring ist nicht installiert.\n" " → pip install keyring") import getpass load_env() username = os.getenv("MAILBOX_DAV_USERNAME", "") if not username: username = input("mailbox.org Benutzername (E-Mail): ").strip() password = getpass.getpass(f"App-Passwort für '{username}' (wird nicht angezeigt): ") if not password: sys.exit("Abgebrochen — kein Passwort eingegeben.") try: keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, password) print(f"✓ Passwort sicher im System-Schlüsselbund gespeichert.") print(f" Service : {KEYRING_SERVICE}") print(f" Username : {KEYRING_USERNAME}") print(f" Hinweis : MAILBOX_DAV_PASSWORD in .env kann jetzt entfernt werden.") except keyring.errors.KeyringError as exc: sys.exit(f"Keyring-Fehler: {exc}") def cmd_delete_password(args) -> None: """Entfernt das gespeicherte Passwort aus dem System-Schlüsselbund.""" if not HAS_KEYRING: sys.exit("Fehler: python-keyring ist nicht installiert.") try: keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME) print(f"✓ Passwort aus keyring entfernt.") except keyring.errors.PasswordDeleteError: print("Kein Passwort im keyring gespeichert.") except keyring.errors.KeyringError as exc: sys.exit(f"Keyring-Fehler: {exc}") # ═══════════════════════════════════════════════════════════════════════════════ # CLI-Argument-Parser # ═══════════════════════════════════════════════════════════════════════════════ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="caldav_crud.py", description="Sichere CalDAV CRUD-Operationen für mailbox.org", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Beispiele: ./caldav_crud.py store-password ./caldav_crud.py list ./caldav_crud.py create --summary "Arzttermin" --start 2026-04-25T09:00 --end 2026-04-25T10:00 ./caldav_crud.py read --uid 550e8400-e29b-41d4-a716-446655440000 ./caldav_crud.py update --uid 550e8400-e29b-41d4-a716-446655440000 --summary "Verschoben" ./caldav_crud.py delete --uid 550e8400-e29b-41d4-a716-446655440000 --force Umgebungsvariablen (.env oder Shell): MAILBOX_DAV_URL CalDAV-Server (Standard: https://dav.mailbox.org) MAILBOX_DAV_USERNAME E-Mail-Adresse oder App-Benutzername MAILBOX_DAV_PASSWORD App-Passwort (besser: keyring verwenden) MAILBOX_DAV_CALENDAR Kalenderbezeichnung (optional, Standard: erster Kalender) """, ) parser.add_argument( "-v", "--verbose", action="store_true", help="Ausführliche Ausgabe (DEBUG-Level)" ) parser.add_argument( "--calendar", metavar="NAME", help="Kalenderbezeichnung (überschreibt MAILBOX_DAV_CALENDAR)" ) subparsers = parser.add_subparsers(dest="command", metavar="BEFEHL") subparsers.required = True # ── list ────────────────────────────────────────────────────────────── subparsers.add_parser("list", help="Alle Events auflisten") # ── create ──────────────────────────────────────────────────────────── p_create = subparsers.add_parser("create", help="Neues Event anlegen") p_create.add_argument("--summary", required=True, help="Titel des Events") p_create.add_argument("--start", required=True, help="Start (ISO-8601, z.B. 2026-04-25T10:00)") p_create.add_argument("--end", required=True, help="Ende (ISO-8601)") p_create.add_argument("--description", default="", help="Beschreibung (optional)") p_create.add_argument("--location", default="", help="Ort (optional)") p_create.add_argument("--uid", default=None, help="Eigene UID (Standard: auto-generiert)") # ── read ────────────────────────────────────────────────────────────── p_read = subparsers.add_parser("read", help="Einzelnes Event anzeigen") p_read.add_argument("--uid", required=True, help="UID des Events") # ── update ──────────────────────────────────────────────────────────── p_update = subparsers.add_parser("update", help="Vorhandenes Event bearbeiten") p_update.add_argument("--uid", required=True, help="UID des Events") p_update.add_argument("--summary", default=None, help="Neuer Titel") p_update.add_argument("--start", default=None, help="Neuer Start (ISO-8601)") p_update.add_argument("--end", default=None, help="Neues Ende (ISO-8601)") p_update.add_argument("--description", default=None, help="Neue Beschreibung") p_update.add_argument("--location", default=None, help="Neuer Ort") # ── delete ──────────────────────────────────────────────────────────── p_delete = subparsers.add_parser("delete", help="Event löschen") p_delete.add_argument("--uid", required=True, help="UID des Events") p_delete.add_argument("--force", action="store_true", help="Ohne Bestätigungsabfrage löschen") # ── Passwort-Verwaltung ─────────────────────────────────────────────── subparsers.add_parser("store-password", help="App-Passwort sicher im Schlüsselbund speichern") subparsers.add_parser("delete-password", help="Passwort aus Schlüsselbund entfernen") return parser # ═══════════════════════════════════════════════════════════════════════════════ # Einstiegspunkt # ═══════════════════════════════════════════════════════════════════════════════ def main() -> None: parser = build_parser() args = parser.parse_args() if args.verbose: logging.getLogger().setLevel(logging.DEBUG) # Passwort-Verwaltung braucht keine CalDAV-Verbindung if args.command == "store-password": cmd_store_password(args) return if args.command == "delete-password": cmd_delete_password(args) return # Konfiguration laden & verbinden cfg = get_config() # --calendar überschreibt Env-Variable if args.calendar: cfg["calendar"] = args.calendar client = connect(cfg) cal = get_calendar(client, cfg.get("calendar")) # CRUD-Dispatcher dispatch = { "list": cmd_list, "create": cmd_create, "read": cmd_read, "update": cmd_update, "delete": cmd_delete, } dispatch[args.command](args, cal) if __name__ == "__main__": main()