import httpx import vobject import logging from typing import Optional from xml.etree import ElementTree as ET logger = logging.getLogger(__name__) CARDDAV_NAMESPACES = { "d": "DAV:", "cs": "http://calendarserver.org/ns/", "card": "urn:ietf:params:xml:ns:carddav", } class CardDAVClient: def __init__(self, server_url: str, username: str, password: str): # Trailing slash entfernen self.base_url = server_url.rstrip("/") self.auth = (username, password) def _client(self) -> httpx.Client: return httpx.Client(auth=self.auth, verify=False, timeout=30) def discover_principal(self) -> Optional[str]: """CardDAV Principal-URL ermitteln. Strategie: 1. Wenn die Server-URL bereits einen Pfad enthält (z.B. /carddav/User), wird sie direkt verwendet — der Admin weiß was er tut. 2. Wenn nur Host:Port angegeben ist, wird /.well-known/carddav probiert, dann fällt es auf /carddav zurück. """ from urllib.parse import urlparse parsed = urlparse(self.base_url) path = (parsed.path or "").strip("/") # Wenn URL schon einen Pfad hat -> direkt verwenden if path: return self.base_url # Sonst: Discovery versuchen try: with self._client() as client: resp = client.request( "PROPFIND", f"{self.base_url}/.well-known/carddav", headers={"Depth": "0", "Content-Type": "application/xml"}, content=""" """, follow_redirects=True, ) if resp.status_code in (200, 207): return str(resp.url).rstrip("/") except Exception as e: logger.warning(f"Well-known discovery failed: {e}") return f"{self.base_url}/carddav" def list_address_books(self) -> list[dict]: """Alle verfügbaren Adressbücher auflisten""" base = self.discover_principal() body = """ """ results = [] try: with self._client() as client: resp = client.request( "PROPFIND", base, headers={"Depth": "1", "Content-Type": "application/xml"}, content=body, ) if resp.status_code != 207: logger.error(f"PROPFIND failed: {resp.status_code}") return results root = ET.fromstring(resp.text) for response in root.findall(".//d:response", CARDDAV_NAMESPACES): href_el = response.find("d:href", CARDDAV_NAMESPACES) rt = response.find(".//d:resourcetype", CARDDAV_NAMESPACES) is_ab = rt is not None and rt.find("card:addressbook", CARDDAV_NAMESPACES) is not None if is_ab and href_el is not None: href = href_el.text name_el = response.find(".//d:displayname", CARDDAV_NAMESPACES) name = name_el.text if name_el is not None else href.split("/")[-2] # Absolute URL aufbauen if href.startswith("/"): from urllib.parse import urlparse parsed = urlparse(self.base_url) full_url = f"{parsed.scheme}://{parsed.netloc}{href}" else: full_url = href results.append({"name": name, "url": full_url, "href": href}) except Exception as e: logger.error(f"list_address_books error: {e}") return results def get_contacts(self, addressbook_url: str) -> list[dict]: """Alle vCards aus einem Adressbuch laden. Verwendet REPORT addressbook-query (RFC 6352) — funktioniert mit Radicale, SOGo, Nextcloud, Baikal und allen anderen RFC-konformen CardDAV-Servern. """ contacts = [] # REPORT addressbook-query holt URLs + vCard-Daten in einem Call report_body = """ """ try: with self._client() as client: resp = client.request( "REPORT", addressbook_url, headers={ "Depth": "1", "Content-Type": "application/xml; charset=utf-8", }, content=report_body, ) if resp.status_code == 207: contacts = self._parse_vcard_response(resp.text) logger.info(f"REPORT returned {len(contacts)} contacts") if contacts: return contacts else: logger.warning(f"REPORT failed ({resp.status_code}), falling back to PROPFIND+GET") # Fallback 1: PROPFIND mit address-data propfind_body = """ """ resp = client.request( "PROPFIND", addressbook_url, headers={"Depth": "1", "Content-Type": "application/xml; charset=utf-8"}, content=propfind_body, ) if resp.status_code == 207: contacts = self._parse_vcard_response(resp.text) if contacts: logger.info(f"PROPFIND returned {len(contacts)} contacts") return contacts # Fallback 2: PROPFIND nur für hrefs + einzelne GET-Requests logger.info("Falling back to PROPFIND + individual GETs") href_body = """ """ resp = client.request( "PROPFIND", addressbook_url, headers={"Depth": "1", "Content-Type": "application/xml; charset=utf-8"}, content=href_body, ) if resp.status_code != 207: logger.error(f"href PROPFIND failed: {resp.status_code}") return contacts # vCard URLs sammeln from urllib.parse import urlparse parsed_base = urlparse(self.base_url) root = ET.fromstring(resp.text) vcard_urls = [] for response in root.findall(".//d:response", CARDDAV_NAMESPACES): href_el = response.find("d:href", CARDDAV_NAMESPACES) ctype_el = response.find(".//d:getcontenttype", CARDDAV_NAMESPACES) if href_el is None: continue href = href_el.text or "" ctype = (ctype_el.text or "") if ctype_el is not None else "" # Nur vCard-Ressourcen, keine Collection if not href.endswith(".vcf") and "vcard" not in ctype.lower(): continue if href.startswith("/"): full_url = f"{parsed_base.scheme}://{parsed_base.netloc}{href}" elif href.startswith("http"): full_url = href else: full_url = f"{addressbook_url.rstrip('/')}/{href}" vcard_urls.append((href, full_url)) logger.info(f"Fetching {len(vcard_urls)} vCards individually") for href, url in vcard_urls: try: r = client.get(url) if r.status_code == 200 and "BEGIN:VCARD" in r.text: parsed = parse_vcard(r.text) if parsed: parsed["_href"] = href parsed["_raw"] = r.text contacts.append(parsed) except Exception as e: logger.warning(f"Failed to GET vCard {url}: {e}") except Exception as e: logger.error(f"get_contacts error: {e}", exc_info=True) return contacts def _parse_vcard_response(self, xml_text: str) -> list[dict]: """Parst eine 207 Multi-Status Antwort und extrahiert vCards""" contacts = [] try: root = ET.fromstring(xml_text) except ET.ParseError as e: logger.error(f"XML parse error: {e}") return contacts for response in root.findall(".//d:response", CARDDAV_NAMESPACES): href_el = response.find("d:href", CARDDAV_NAMESPACES) etag_el = response.find(".//d:getetag", CARDDAV_NAMESPACES) vcard_el = response.find(".//card:address-data", CARDDAV_NAMESPACES) if vcard_el is not None and vcard_el.text: vcard_data = vcard_el.text.strip() if "BEGIN:VCARD" in vcard_data: parsed = parse_vcard(vcard_data) if parsed: parsed["_href"] = href_el.text if href_el is not None else "" parsed["_etag"] = etag_el.text if etag_el is not None else "" parsed["_raw"] = vcard_data contacts.append(parsed) return contacts def test_connection(self) -> tuple[bool, str]: """Verbindungstest""" try: books = self.list_address_books() if books is not None: return True, f"{len(books)} Adressbuch/Adressbücher gefunden" return False, "Keine Adressbücher gefunden" except Exception as e: return False, str(e) def parse_vcard(vcard_text: str) -> Optional[dict]: """vCard → Dictionary für Microsoft Graph""" try: vcard = vobject.readOne(vcard_text) except Exception as e: logger.warning(f"vCard parse error: {e}") return None def get(attr): try: return getattr(vcard, attr).value except AttributeError: return None def get_all(attr): try: return [x.value for x in getattr(vcard, attr + "_list", [])] except (AttributeError, TypeError): return [] result = {} # Name name = get("n") if name: result["givenName"] = name.given or "" result["surname"] = name.family or "" result["middleName"] = name.additional or "" result["title"] = name.prefix or "" result["generation"] = name.suffix or "" fn = get("fn") if fn: result["displayName"] = fn elif "givenName" in result or "surname" in result: result["displayName"] = f"{result.get('givenName', '')} {result.get('surname', '')}".strip() # E-Mails emails = [] try: for email_obj in getattr(vcard, "email_list", []): addr = email_obj.value params = email_obj.params types = [t.lower() for t in params.get("TYPE", [])] address_type = "work" if "work" in types else ("home" if "home" in types else "other") emails.append({"address": addr, "name": result.get("displayName", ""), "type": address_type}) except Exception: pass if emails: result["emailAddresses"] = emails # Telefon phones_business = [] phones_home = [] mobile = None try: for tel_obj in getattr(vcard, "tel_list", []): num = tel_obj.value types = [t.lower() for t in tel_obj.params.get("TYPE", [])] if "cell" in types or "mobile" in types: mobile = num elif "home" in types: phones_home.append(num) else: phones_business.append(num) except Exception: pass if phones_business: result["businessPhones"] = phones_business if phones_home: result["homePhones"] = phones_home if mobile: result["mobilePhone"] = mobile # Organisation org = get("org") if org: if isinstance(org, list): result["companyName"] = org[0] if org else "" result["department"] = org[1] if len(org) > 1 else "" else: result["companyName"] = str(org) title = get("title") if title: result["jobTitle"] = title # Adresse try: for adr_obj in getattr(vcard, "adr_list", []): adr = adr_obj.value types = [t.lower() for t in adr_obj.params.get("TYPE", [])] addr_dict = { "street": adr.street or "", "city": adr.city or "", "state": adr.region or "", "postalCode": adr.code or "", "countryOrRegion": adr.country or "", } if "home" in types: result["homeAddress"] = addr_dict else: result["businessAddress"] = addr_dict break # nur erste Adresse except Exception: pass # Notizen note = get("note") if note: result["personalNotes"] = note # URL url = get("url") if url: result["businessHomePage"] = url # UID für Identifizierung uid = get("uid") result["_uid"] = uid or "" return result