diff --git a/server.py b/server.py
index 8f741f1..4f0e9e4 100644
--- a/server.py
+++ b/server.py
@@ -739,24 +739,122 @@ def parse_dav_propfind(xml_text):
return results
+# CalDAV/CardDAV helpers
+
+DAV_NS = {"D": "DAV:"}
+CAL_NS = {"CAL": "urn:ietf:params:xml:ns:caldav"}
+CARD_NS = {"CARD": "urn:ietf:params:xml:ns:carddav"}
+
+
+def dav_session():
+ if not config.DAV_BASE_URL or not config.DAV_USERNAME or not config.DAV_PASSWORD:
+ raise ValueError("DAV_BASE_URL, DAV_USERNAME, or DAV_PASSWORD not configured")
+ s = requests.Session()
+ s.auth = (config.DAV_USERNAME, config.DAV_PASSWORD)
+ s.verify = config.DAV_VERIFY_TLS
+ return s
+
+
+def dav_request(s, method, url, data=None, headers=None, timeout=20):
+ try:
+ r = s.request(method, url, data=data, headers=headers or {}, timeout=timeout)
+ return r
+ except Exception as e:
+ logger.error(f"DAV request error: {method} {url}: {e}")
+ raise
+
+
+def parse_dav_propfind(xml_text):
+ try:
+ root = ET.fromstring(xml_text)
+ except ET.ParseError:
+ return []
+
+ results = []
+ for response in root.findall(".//D:response", DAV_NS):
+ href_el = response.find("D:href", DAV_NS)
+ href = (href_el.text or "").strip() if href_el is not None else ""
+ props = {}
+ propfind = response.find("D:propstat/D:prop", DAV_NS)
+ if propfind is not None:
+ for child in propfind:
+ tag = re.sub(r".*\{.*\}", "", child.tag)
+ text = (child.text or "").strip()
+ props[tag] = text
+ if href:
+ results.append({"href": href, "props": props})
+ return results
+
+
+def get_user_principal(service="caldav"):
+ """
+ Discover the user's principal URL using /.well-known/{service} redirect
+ and then PROPFIND for current-user-principal.
+ """
+ base = config.DAV_BASE_URL.rstrip("/")
+ well_known = base + f"/.well-known/{service}"
+ s = dav_session()
+
+ # First: GET /.well-known/caldav (or carddav) to get the redirect URL
+ r = dav_request(s, "GET", well_known)
+ r.raise_for_status()
+ dave_url = r.url # after redirects
+
+ # Then: PROPFIND on that URL to find current-user-principal
+ r = dav_request(s, "PROPFIND", dave_url,
+ headers={"Depth": "0", "Content-Type": "application/xml"},
+ data="""
+
+
+ """)
+ r.raise_for_status()
+ entries = parse_dav_propfind(r.text)
+ for e in entries:
+ principal = e.get("props", {}).get("current-user-principal", "")
+ if principal:
+ return principal
+ raise RuntimeError("Could not find user principal")
+
+
# CardDAV helpers
-def carddav_base_url():
- return config.DAV_BASE_URL.rstrip("/") + "/cards/"
-
-
def list_carddav_addressbooks():
+ """
+ Discover addressbooks via the user principal and addressbook-home-set.
+ """
s = dav_session()
- r = dav_request(s, "PROPFIND", carddav_base_url(),
- headers={"Depth": "1",
- "Content-Type": "application/xml"},
+ principal = get_user_principal("carddav")
+
+ # PROPFIND on principal to get addressbook-home-set
+ r = dav_request(s, "PROPFIND", principal,
+ headers={"Depth": "0", "Content-Type": "application/xml"},
data="""
-
-
-
-
-
- """)
+
+
+
+
+ """)
+ r.raise_for_status()
+ entries = parse_dav_propfind(r.text)
+ home_set = None
+ for e in entries:
+ val = e.get("props", {}).get("addressbook-home-set", "")
+ if val:
+ home_set = val
+ break
+ if not home_set:
+ return []
+
+ # PROPFIND on addressbook-home-set to list addressbooks
+ r = dav_request(s, "PROPFIND", home_set,
+ headers={"Depth": "1", "Content-Type": "application/xml"},
+ data="""
+
+
+
+
+
+ """)
r.raise_for_status()
entries = parse_dav_propfind(r.text)
addressbooks = []
@@ -764,7 +862,7 @@ def list_carddav_addressbooks():
href = e["href"]
props = e.get("props", {})
rt = props.get("resourcetype", "")
- if "/cards/" in href and href.endswith("/") and ("addressbook" in rt.lower() or "addressbook" in href.lower()):
+ if href.endswith("/") and ("addressbook" in rt.lower() or "addressbook" in href.lower()):
addressbooks.append({
"href": href,
"name": props.get("displayname", href),
@@ -785,16 +883,14 @@ def search_carddav_contacts(addressbook_href: str, query: str):
s = dav_session()
safe_query = _xml_escape(query)
req_body = f"""
-
-
-
-
- {safe_query}
-
-
-
- """
+
+
+
+ {safe_query}
+
+
+ """
r = dav_request(s, "REPORT", addressbook_href,
headers={"Content-Type": "application/xml"},
data=req_body)
@@ -851,22 +947,43 @@ def delete_carddav_contact(href: str):
# CalDAV helpers
-def caldav_base_url():
- return config.DAV_BASE_URL.rstrip("/") + "/calendars/"
-
-
def list_caldav_calendars():
+ """
+ Discover calendars via the user principal and calendar-home-set.
+ """
s = dav_session()
- r = dav_request(s, "PROPFIND", caldav_base_url(),
- headers={"Depth": "1",
- "Content-Type": "application/xml"},
+ principal = get_user_principal("caldav")
+
+ # PROPFIND on principal to get calendar-home-set
+ r = dav_request(s, "PROPFIND", principal,
+ headers={"Depth": "0", "Content-Type": "application/xml"},
data="""
-
-
-
-
-
- """)
+
+
+
+
+ """)
+ r.raise_for_status()
+ entries = parse_dav_propfind(r.text)
+ home_set = None
+ for e in entries:
+ val = e.get("props", {}).get("calendar-home-set", "")
+ if val:
+ home_set = val
+ break
+ if not home_set:
+ return []
+
+ # PROPFIND on calendar-home-set to list calendars
+ r = dav_request(s, "PROPFIND", home_set,
+ headers={"Depth": "1", "Content-Type": "application/xml"},
+ data="""
+
+
+
+
+
+ """)
r.raise_for_status()
entries = parse_dav_propfind(r.text)
calendars = []
@@ -874,7 +991,7 @@ def list_caldav_calendars():
href = e["href"]
props = e.get("props", {})
rt = props.get("resourcetype", "")
- if "/calendars/" in href and href.endswith("/") and ("calendar" in rt.lower() or "calendar" in href.lower()):
+ if href.endswith("/") and ("calendar" in rt.lower() or "calendar" in href.lower()):
calendars.append({
"href": href,
"name": props.get("displayname", href.split("/")[-2]),