From b9de821fedcdf5a00fd8d1e1028440985cedcdb3 Mon Sep 17 00:00:00 2001 From: Chris-Robin Ennen Date: Wed, 12 Aug 2026 04:12:22 +0200 Subject: [PATCH 01/15] fix: discover CardDAV addressbooks by namespace --- bdaysync/cardav_client.py | 86 ++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/bdaysync/cardav_client.py b/bdaysync/cardav_client.py index 0903233..109adf1 100644 --- a/bdaysync/cardav_client.py +++ b/bdaysync/cardav_client.py @@ -6,6 +6,7 @@ import logging from datetime import datetime from typing import List, Dict, Optional +from xml.etree import ElementTree import vobject import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth @@ -15,7 +16,7 @@ class CardDAVClient: """Client for reading contacts from CardDAV server""" - + def __init__(self, server_url: str, username: str, password: str): self.server_url = server_url.rstrip('/') self.username = username @@ -37,9 +38,19 @@ def _test_auth_and_discover(self): try: # Test Basic auth first - headers = {'Depth': '1'} + headers = { + 'Content-Type': 'application/xml; charset=utf-8', + 'Depth': '1', + } + propfind_body = ''' + + + + + ''' response = requests.request('PROPFIND', self.server_url, - auth=self.basic_auth, headers=headers, timeout=10) + auth=self.basic_auth, headers=headers, + data=propfind_body, timeout=10) logger.info(f"Basic auth response: {response.status_code}") if response.status_code in [200, 207]: @@ -49,7 +60,8 @@ def _test_auth_and_discover(self): # Try Digest auth logger.info("Basic auth failed, trying Digest authentication...") response = requests.request('PROPFIND', self.server_url, - auth=self.digest_auth, headers=headers, timeout=10) + auth=self.digest_auth, headers=headers, + data=propfind_body, timeout=10) logger.info(f"Digest auth response: {response.status_code}") if response.status_code in [200, 207]: @@ -85,40 +97,50 @@ def _test_auth_and_discover(self): def _extract_addressbooks(self, xml_response: str) -> List[str]: """Extract addressbook collection URLs from PROPFIND response""" + return self._find_addressbooks(xml_response) + + def _is_addressbook(self, xml_response: str) -> bool: + """Check if the response indicates this URL is an addressbook collection""" + return bool(self._find_addressbooks(xml_response)) + + def _find_addressbooks(self, xml_response: str) -> List[str]: + """Find CardDAV addressbook collections in a DAV multistatus response.""" + dav_namespace = 'DAV:' + carddav_namespace = 'urn:ietf:params:xml:ns:carddav' + + try: + root = ElementTree.fromstring(xml_response) + except ElementTree.ParseError as error: + logger.warning(f"Could not parse CardDAV discovery XML: {error}") + return [] + addressbooks = [] - - # Find all response blocks - response_pattern = r']*>(.*?)' - responses = re.findall(response_pattern, xml_response, re.DOTALL | re.IGNORECASE) - - for response_block in responses: - # Extract href from this response block - href_match = re.search(r']*>([^<]+)', response_block, re.IGNORECASE) - if not href_match: + for response in root.findall(f'{{{dav_namespace}}}response'): + href = response.findtext(f'{{{dav_namespace}}}href') + if not href: continue - - href = href_match.group(1).strip() + + has_addressbook_type = False + for propstat in response.findall(f'{{{dav_namespace}}}propstat'): + status = propstat.findtext(f'{{{dav_namespace}}}status', '') + if not status.startswith('HTTP/') or ' 2' not in status: + continue + + resource_type = propstat.find(f'{{{dav_namespace}}}prop/{{{dav_namespace}}}resourcetype') + if resource_type is not None and resource_type.find(f'{{{carddav_namespace}}}addressbook') is not None: + has_addressbook_type = True + break + + href = href.strip() logger.debug(f"Found href: {href}") - - # Check if this response contains addressbook resourcetype - if ('card:addressbook' in response_block or - 'addressbook' in response_block.lower() and - ' bool: - """Check if the response indicates this URL is an addressbook collection""" - return ('card:addressbook' in xml_response or - ('addressbook' in xml_response.lower() and - ' List[Dict]: """Fetch all contacts from all discovered addressbooks""" all_contacts = [] From a7b2a96acebd7178bfff58dd30653288aebf9e56 Mon Sep 17 00:00:00 2001 From: Chris-Robin Ennen Date: Wed, 12 Aug 2026 04:16:47 +0200 Subject: [PATCH 02/15] fix: discover vCards by content type --- bdaysync/cardav_client.py | 51 +++++++++++++-------------------------- 1 file changed, 17 insertions(+), 34 deletions(-) diff --git a/bdaysync/cardav_client.py b/bdaysync/cardav_client.py index 109adf1..7fdc92e 100644 --- a/bdaysync/cardav_client.py +++ b/bdaysync/cardav_client.py @@ -2,7 +2,6 @@ CardDAV client for fetching contacts with birthdays """ -import re import logging from datetime import datetime from typing import List, Dict, Optional @@ -168,9 +167,7 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: propfind_body = ''' - - ''' @@ -228,38 +225,24 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: def _extract_vcard_urls(self, xml_response: str) -> List[str]: """Extract vCard URLs from PROPFIND response""" + dav_namespace = 'DAV:' + + try: + root = ElementTree.fromstring(xml_response) + except ElementTree.ParseError as error: + logger.warning(f"Could not parse vCard discovery XML: {error}") + return [] + urls = [] - - # Find all href elements containing .vcf files - vcf_pattern = r']*>([^<]*\.vcf)' - vcf_matches = re.findall(vcf_pattern, xml_response, re.IGNORECASE) - - for url in vcf_matches: - url = url.strip() - if url: - urls.append(url) - logger.debug(f"Found vCard URL: {url}") - - # Also try a more general pattern for any vcard content type - href_pattern = r']*>([^<]+)' - content_type_pattern = r']*>([^<]*vcard[^<]*)' - - href_matches = re.findall(href_pattern, xml_response, re.IGNORECASE) - content_matches = re.findall(content_type_pattern, xml_response, re.IGNORECASE) - - # If we found content type matches, try to match them with hrefs - if content_matches and not urls: - for href in href_matches: - href = href.strip() - if not href.endswith('/') and not href.endswith('.vcf'): - # Check if this href appears near a vcard content type - href_index = xml_response.find(f'{href}') - if href_index > 0: - # Look for vcard content type within 500 chars after href - nearby_text = xml_response[href_index:href_index + 500] - if 'vcard' in nearby_text.lower(): - urls.append(href) - logger.debug(f"Found vCard URL by content type: {href}") + for response in root.findall(f'{{{dav_namespace}}}response'): + href = response.findtext(f'{{{dav_namespace}}}href') + content_type = response.findtext( + f'{{{dav_namespace}}}propstat/{{{dav_namespace}}}prop/' + f'{{{dav_namespace}}}getcontenttype' + ) + if href and content_type and 'vcard' in content_type.lower(): + urls.append(href.strip()) + logger.debug(f"Found vCard URL: {href.strip()}") logger.info(f"Extracted {len(urls)} vCard URLs") return urls From f33b237ee827fc78323c25343cd261694fc7d02f Mon Sep 17 00:00:00 2001 From: Chris-Robin Ennen Date: Wed, 12 Aug 2026 04:20:20 +0200 Subject: [PATCH 03/15] refactor: cleanup --- bdaysync/cardav_client.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/bdaysync/cardav_client.py b/bdaysync/cardav_client.py index 7fdc92e..ae94e39 100644 --- a/bdaysync/cardav_client.py +++ b/bdaysync/cardav_client.py @@ -76,12 +76,7 @@ def _test_auth_and_discover(self): self.addressbook_urls = self._extract_addressbooks(response.text) if not self.addressbook_urls: - # If no addressbooks found, maybe this URL IS an addressbook - if self._is_addressbook(response.text): - logger.info("Provided URL appears to be a single addressbook") - self.addressbook_urls = [self.server_url] - else: - raise Exception("No addressbooks found at the provided URL") + raise Exception("No addressbooks found at the provided URL") logger.info(f"Discovered {len(self.addressbook_urls)} addressbooks:") for ab_url in self.addressbook_urls: @@ -98,10 +93,6 @@ def _extract_addressbooks(self, xml_response: str) -> List[str]: """Extract addressbook collection URLs from PROPFIND response""" return self._find_addressbooks(xml_response) - def _is_addressbook(self, xml_response: str) -> bool: - """Check if the response indicates this URL is an addressbook collection""" - return bool(self._find_addressbooks(xml_response)) - def _find_addressbooks(self, xml_response: str) -> List[str]: """Find CardDAV addressbook collections in a DAV multistatus response.""" dav_namespace = 'DAV:' From 3877679f4b392013e4668cac4bf590ba39b01c64 Mon Sep 17 00:00:00 2001 From: Chris-Robin Ennen Date: Wed, 12 Aug 2026 04:25:23 +0200 Subject: [PATCH 04/15] fix: retain cron checks during long runs --- bdaysync/scheduler.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bdaysync/scheduler.py b/bdaysync/scheduler.py index 856a437..c7ec48f 100644 --- a/bdaysync/scheduler.py +++ b/bdaysync/scheduler.py @@ -27,6 +27,7 @@ def __init__(self, sync_func, diagnostic_func): self.startup_delay = config['startup_delay'] self.last_sync = None + self.last_schedule_check = datetime.now() # Setup signal handlers for graceful shutdown signal.signal(signal.SIGTERM, self._signal_handler) @@ -54,12 +55,10 @@ def _should_sync_interval(self): return True return datetime.now() - self.last_sync >= timedelta(hours=self.sync_interval_hours) - def _should_sync_cron(self, schedule): + def _should_sync_cron(self, schedule, last_check, now): """Check if we should sync based on cron schedule""" try: - cron = croniter(schedule, datetime.now() - timedelta(minutes=1)) - next_time = cron.get_next(datetime) - return next_time <= datetime.now() + return croniter(schedule, last_check).get_next(datetime) <= now except: return False @@ -141,6 +140,7 @@ def run_daemon(self): while self.running: try: loop_count += 1 + now = datetime.now() # Check if it's time for a sync sync_needed = False @@ -149,9 +149,14 @@ def run_daemon(self): if self.sync_interval_hours > 0: sync_needed = self._should_sync_interval() else: - sync_needed = self._should_sync_cron(self.sync_schedule) + sync_needed = self._should_sync_cron( + self.sync_schedule, self.last_schedule_check, now + ) - diagnostic_needed = self._should_sync_cron(self.diagnostic_schedule) + diagnostic_needed = self._should_sync_cron( + self.diagnostic_schedule, self.last_schedule_check, now + ) + self.last_schedule_check = now if diagnostic_needed: self._perform_sync(diagnostic=True) From a7942b3fab4ba44ebf6774b0a3bead6c1133d919 Mon Sep 17 00:00:00 2001 From: Chris-Robin Ennen Date: Wed, 12 Aug 2026 04:25:41 +0200 Subject: [PATCH 05/15] fix: report scheduler configuration errors --- bdaysync/scheduler.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bdaysync/scheduler.py b/bdaysync/scheduler.py index c7ec48f..2df72cf 100644 --- a/bdaysync/scheduler.py +++ b/bdaysync/scheduler.py @@ -59,7 +59,8 @@ def _should_sync_cron(self, schedule, last_check, now): """Check if we should sync based on cron schedule""" try: return croniter(schedule, last_check).get_next(datetime) <= now - except: + except Exception as e: + logger.error(f"Invalid cron schedule '{schedule}': {e}") return False def _perform_sync(self, diagnostic=False): @@ -104,7 +105,8 @@ def _get_next_schedule_info(self): 'sync_schedule': self.sync_schedule, 'diagnostic_schedule': self.diagnostic_schedule } - except: + except Exception as e: + logger.error(f"Could not calculate the next scheduled run: {e}") return None def run_daemon(self): From 41107296c164890770c17f59d0a7326caaa09741 Mon Sep 17 00:00:00 2001 From: Chris-Robin Ennen Date: Wed, 12 Aug 2026 04:26:17 +0200 Subject: [PATCH 06/15] refactor: remove unused scheduler entry point --- bdaysync/scheduler.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/bdaysync/scheduler.py b/bdaysync/scheduler.py index 2df72cf..9050ea1 100644 --- a/bdaysync/scheduler.py +++ b/bdaysync/scheduler.py @@ -180,9 +180,3 @@ def run_daemon(self): self._wait_with_interrupt_check(60) logger.info("Scheduler daemon stopped") - - def run_once(self): - """Run sync once and exit""" - logger.info("Running single sync operation...") - success = self._perform_sync() - return 0 if success else 1 From f781ace16825540c944182d6395b683692d205a9 Mon Sep 17 00:00:00 2001 From: HubEight <18333673+HubEight@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:20:59 +0000 Subject: [PATCH 07/15] Accept iCloud vCard hrefs when getcontenttype is missing PR #1 discovers addressbooks via DAV/CardDAV namespaces, then only keeps members whose getcontenttype contains "vcard". iCloud omits that property and names contacts *.vcf, so listing returned zero URLs. Treat a vcard MIME type or a .vcf href as a contact, and skip collection hrefs that end with a slash. --- bdaysync/cardav_client.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/bdaysync/cardav_client.py b/bdaysync/cardav_client.py index ae94e39..09b32d7 100644 --- a/bdaysync/cardav_client.py +++ b/bdaysync/cardav_client.py @@ -226,14 +226,18 @@ def _extract_vcard_urls(self, xml_response: str) -> List[str]: urls = [] for response in root.findall(f'{{{dav_namespace}}}response'): - href = response.findtext(f'{{{dav_namespace}}}href') - content_type = response.findtext( + href = (response.findtext(f'{{{dav_namespace}}}href') or '').strip() + if not href or href.endswith('/'): + continue + content_type = (response.findtext( f'{{{dav_namespace}}}propstat/{{{dav_namespace}}}prop/' f'{{{dav_namespace}}}getcontenttype' - ) - if href and content_type and 'vcard' in content_type.lower(): - urls.append(href.strip()) - logger.debug(f"Found vCard URL: {href.strip()}") + ) or '') + # SOGo/sabre set getcontenttype to a vcard MIME type. iCloud omits + # that property and uses *.vcf hrefs instead. + if 'vcard' in content_type.lower() or href.lower().endswith('.vcf'): + urls.append(href) + logger.debug(f"Found vCard URL: {href}") logger.info(f"Extracted {len(urls)} vCard URLs") return urls From 9c9edd262b31566de584ef987044f728e5cfc9d8 Mon Sep 17 00:00:00 2001 From: HubEight <18333673+HubEight@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:42:42 +0000 Subject: [PATCH 08/15] Add GitHub Actions workflow to publish the image to GHCR Build on push to main and fix/carddav-icloud-vcf, push to ghcr.io/hubeight/bdaysync. Do not tag latest. --- .github/workflows/docker.yml | 119 ++++++----------------------------- 1 file changed, 19 insertions(+), 100 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index aaf1232..4e98a60 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,20 +1,13 @@ -name: Build and Publish Docker Image +name: Build and publish image on: push: branches: - main - - develop - tags: - - "v*" - pull_request: - branches: - - main - schedule: - # Rebuild weekly on Sundays at 2 AM UTC to get base image updates - - cron: "0 2 * * 0" + - fix/carddav-icloud-vcf + paths-ignore: + - '**.md' workflow_dispatch: - # Allow manual triggering env: REGISTRY: ghcr.io @@ -26,111 +19,37 @@ jobs: permissions: contents: read packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio when running outside of PRs. - id-token: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v6 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + - name: Log in to GHCR + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata + - name: Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false tags: | - # set latest tag for default branch + type=sha,prefix=sha- type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} - # weekly rebuilds get a date tag - type=schedule,pattern={{date 'YYYYMMDD'}} - labels: | - org.opencontainers.image.title=Birthday Sync - org.opencontainers.image.description=Automated CardDAV to CalDAV birthday synchronization service - org.opencontainers.image.vendor=${{ github.repository_owner }} + type=raw,value=icloud-vcf,enable=${{ github.ref == 'refs/heads/fix/carddav-icloud-vcf' }} - - name: Build and push Docker image - id: build-and-push - uses: docker/build-push-action@v5 + - name: Build and push + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 with: context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - # Build-time metadata build-args: | - BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} + BUILD_DATE=${{ github.event.head_commit.timestamp }} VCS_REF=${{ github.sha }} - VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} - - test: - runs-on: ubuntu-latest - needs: build-and-push - if: github.event_name == 'pull_request' - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run basic tests - run: | - # Test imports - cd bdaysync - python -c "import main, config, cardav_client, caldav_client, scheduler" - - # Test help output - python main.py --help - - # Test config validation (should fail without env vars) - python -c "from config import validate_environment; exit(0 if not validate_environment() else 1)" - - security-scan: - runs-on: ubuntu-latest - needs: build-and-push - if: github.event_name != 'pull_request' - permissions: - contents: read - packages: read - security-events: write - - steps: - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} - format: "sarif" - output: "trivy-results.sarif" - - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: "trivy-results.sarif" + VERSION=${{ github.ref_name }} From 72c2f3c8951669ad663e6a4b6d7546ed5f9d3bcb Mon Sep 17 00:00:00 2001 From: HubEight <18333673+HubEight@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:45:48 +0000 Subject: [PATCH 09/15] Restore the existing Docker publish workflow The previous commit replaced the repo workflow that was already running on this branch via workflow_dispatch. --- .github/workflows/docker.yml | 119 +++++++++++++++++++++++++++++------ 1 file changed, 100 insertions(+), 19 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4e98a60..aaf1232 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,13 +1,20 @@ -name: Build and publish image +name: Build and Publish Docker Image on: push: branches: - main - - fix/carddav-icloud-vcf - paths-ignore: - - '**.md' + - develop + tags: + - "v*" + pull_request: + branches: + - main + schedule: + # Rebuild weekly on Sundays at 2 AM UTC to get base image updates + - cron: "0 2 * * 0" workflow_dispatch: + # Allow manual triggering env: REGISTRY: ghcr.io @@ -19,37 +26,111 @@ jobs: permissions: contents: read packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Checkout repository + uses: actions/checkout@v4 - - name: Log in to GHCR - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Docker metadata + - name: Extract metadata id: meta - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - flavor: | - latest=false tags: | - type=sha,prefix=sha- + # set latest tag for default branch type=ref,event=branch - type=raw,value=icloud-vcf,enable=${{ github.ref == 'refs/heads/fix/carddav-icloud-vcf' }} + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + # weekly rebuilds get a date tag + type=schedule,pattern={{date 'YYYYMMDD'}} + labels: | + org.opencontainers.image.title=Birthday Sync + org.opencontainers.image.description=Automated CardDAV to CalDAV birthday synchronization service + org.opencontainers.image.vendor=${{ github.repository_owner }} - - name: Build and push - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 with: context: . - push: true + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + # Build-time metadata build-args: | - BUILD_DATE=${{ github.event.head_commit.timestamp }} + BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} VCS_REF=${{ github.sha }} - VERSION=${{ github.ref_name }} + VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} + + test: + runs-on: ubuntu-latest + needs: build-and-push + if: github.event_name == 'pull_request' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run basic tests + run: | + # Test imports + cd bdaysync + python -c "import main, config, cardav_client, caldav_client, scheduler" + + # Test help output + python main.py --help + + # Test config validation (should fail without env vars) + python -c "from config import validate_environment; exit(0 if not validate_environment() else 1)" + + security-scan: + runs-on: ubuntu-latest + needs: build-and-push + if: github.event_name != 'pull_request' + permissions: + contents: read + packages: read + security-events: write + + steps: + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + format: "sarif" + output: "trivy-results.sarif" + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: "trivy-results.sarif" From 7db3ebd494ee8f1a5e103ee795e032efd4ee6e7a Mon Sep 17 00:00:00 2001 From: HubEight <18333673+HubEight@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:51:45 +0000 Subject: [PATCH 10/15] Fix Trivy image ref for GHCR and branch names Trivy failed because it used HubEight (must be lowercase) and fix/carddav-icloud-vcf (slash is invalid in a Docker tag). Use the same sanitized tag docker/metadata-action publishes. --- .github/workflows/docker.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index aaf1232..d45e1db 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -123,10 +123,16 @@ jobs: security-events: write steps: + - name: Resolve image reference + run: | + repo="${GITHUB_REPOSITORY,,}" + tag="${GITHUB_REF_NAME//\//-}" + echo "TRIVY_IMAGE=${REGISTRY}/${repo}:${tag}" >> "$GITHUB_ENV" + - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: - image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + image-ref: ${{ env.TRIVY_IMAGE }} format: "sarif" output: "trivy-results.sarif" From b015dde536153dd20036b26e07300fe3cba5e81c Mon Sep 17 00:00:00 2001 From: HubEight <18333673+HubEight@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:35:53 +0000 Subject: [PATCH 11/15] Retry CardDAV vCard GETs on connection errors iCloud lookups occasionally fail with ENETUNREACH (IPv6 without a route). Retry the GET up to three times with a short backoff so one blip does not drop a contact for the whole run. --- bdaysync/cardav_client.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/bdaysync/cardav_client.py b/bdaysync/cardav_client.py index 09b32d7..3b53a05 100644 --- a/bdaysync/cardav_client.py +++ b/bdaysync/cardav_client.py @@ -3,6 +3,7 @@ """ import logging +import time from datetime import datetime from typing import List, Dict, Optional from xml.etree import ElementTree @@ -144,6 +145,19 @@ def get_contacts(self) -> List[Dict]: logger.info(f"Total contacts with birthdays across all addressbooks: {len(all_contacts)}") return all_contacts + def _http_get_retry(self, url: str, attempts: int = 3): + """GET with retries for transient connection errors (e.g. IPv6 unreachable).""" + last_error = None + for i in range(1, attempts + 1): + try: + return requests.get(url, auth=self.auth, timeout=10) + except requests.exceptions.RequestException as e: + last_error = e + logger.warning(f"GET failed ({i}/{attempts}) for {url}: {e}") + if i < attempts: + time.sleep(i) + raise last_error + def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: """Fetch contacts from a specific addressbook""" contacts = [] @@ -185,7 +199,7 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: full_url = self._resolve_url(vcard_url) logger.debug(f"Fetching vCard {i+1}/{len(vcard_urls)} from: {full_url}") - vcard_response = requests.get(full_url, auth=self.auth, timeout=10) + vcard_response = self._http_get_retry(full_url) logger.debug(f"vCard response status: {vcard_response.status_code}") if vcard_response.status_code == 200: From aed72eeae3848e83ff5c1e76f8e1a442fc79234f Mon Sep 17 00:00:00 2001 From: HubEight <18333673+HubEight@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:33:56 +0000 Subject: [PATCH 12/15] Delete orphan birthday events when the contact BDAY is gone After a complete CardDAV fetch, remove calendar events whose UID matches birthday-{slug}-{YYYYMMDD} if that slug is not in the current set of contacts that still have a birthday. Skip delete when any vCard GET failed or the calendar listing is empty. --- bdaysync/caldav_client.py | 34 ++++++++++++++++++++++++++++++++- bdaysync/cardav_client.py | 16 ++++++++++++++++ bdaysync/main.py | 40 +++++++++++++++++++++------------------ 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/bdaysync/caldav_client.py b/bdaysync/caldav_client.py index c7cde89..8b4619e 100644 --- a/bdaysync/caldav_client.py +++ b/bdaysync/caldav_client.py @@ -3,10 +3,13 @@ """ import logging +import re from datetime import datetime, timedelta -from typing import Dict, Optional +from typing import Dict, List, Optional import vobject import caldav + +BIRTHDAY_UID_RE = re.compile(r'^birthday-(.+)-(\d{8})$') from config import get_birthday_config logger = logging.getLogger(__name__) @@ -164,6 +167,35 @@ def _format_reminder_message(self, name: str, days_before: int) -> str: else: return f"{name}'s birthday is in {days_before} days!" + def delete_orphans(self, contacts: List[Dict]) -> int: + """Delete birthday-* events whose name slug is not in the current BDAY set.""" + wanted = {contact['name'].replace(' ', '-').lower() for contact in contacts} + events = self.calendar.events() + if not events: + logger.warning("No calendar events listed; skipping orphan delete") + return 0 + + deleted = 0 + for ev in events: + try: + parsed = vobject.readOne(ev.data) + if not hasattr(parsed, 'vevent') or not hasattr(parsed.vevent, 'uid'): + continue + uid = parsed.vevent.uid.value + match = BIRTHDAY_UID_RE.match(uid) + if not match: + continue + slug = match.group(1) + if slug in wanted: + continue + logger.info(f"Deleting orphan birthday event: {uid}") + ev.delete() + deleted += 1 + except Exception as e: + logger.warning(f"Error while considering event for orphan delete: {e}") + continue + return deleted + def _find_existing_event(self, name: str, date) -> Optional: """Find existing birthday event for a contact""" try: diff --git a/bdaysync/cardav_client.py b/bdaysync/cardav_client.py index 3b53a05..2099980 100644 --- a/bdaysync/cardav_client.py +++ b/bdaysync/cardav_client.py @@ -29,6 +29,9 @@ def __init__(self, server_url: str, username: str, password: str): # Discover addressbooks self.addressbook_urls = [] + self.vcard_listed = 0 + self.vcard_fetched_ok = 0 + self.fetch_complete = False self._test_auth_and_discover() def _test_auth_and_discover(self): @@ -135,6 +138,9 @@ def _find_addressbooks(self, xml_response: str) -> List[str]: def get_contacts(self) -> List[Dict]: """Fetch all contacts from all discovered addressbooks""" all_contacts = [] + self.vcard_listed = 0 + self.vcard_fetched_ok = 0 + self.fetch_complete = False for addressbook_url in self.addressbook_urls: logger.info(f"Processing addressbook: {addressbook_url}") @@ -142,6 +148,13 @@ def get_contacts(self) -> List[Dict]: all_contacts.extend(contacts) logger.info(f"Found {len(contacts)} contacts with birthdays in this addressbook") + self.fetch_complete = ( + self.vcard_listed > 0 and self.vcard_fetched_ok == self.vcard_listed + ) + logger.info( + f"CardDAV fetch {self.vcard_fetched_ok}/{self.vcard_listed} vCards " + f"(complete={self.fetch_complete})" + ) logger.info(f"Total contacts with birthdays across all addressbooks: {len(all_contacts)}") return all_contacts @@ -192,6 +205,8 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: if not vcard_urls: logger.debug("No vCard URLs found in this addressbook") return contacts + + self.vcard_listed += len(vcard_urls) # Fetch each vCard for i, vcard_url in enumerate(vcard_urls): @@ -203,6 +218,7 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: logger.debug(f"vCard response status: {vcard_response.status_code}") if vcard_response.status_code == 200: + self.vcard_fetched_ok += 1 logger.debug(f"vCard content preview: {vcard_response.text[:200]}...") contact = self._parse_vcard(vcard_response.text) if contact: diff --git a/bdaysync/main.py b/bdaysync/main.py index af476c3..2c1d838 100644 --- a/bdaysync/main.py +++ b/bdaysync/main.py @@ -98,24 +98,28 @@ def main_sync(): if not contacts: logger.warning("No contacts with birthdays found") - return False - - logger.info(f"Found {len(contacts)} contacts with birthdays") - - # Create birthday events - created_count = 0 - current_year = datetime.now().year - - for contact in contacts: - logger.info(f"Processing birthday for: {contact['name']} ({contact['birthday']})") - if caldav_client.create_birthday_event(contact, current_year): - created_count += 1 - - # Also create for next year - if caldav_client.create_birthday_event(contact, current_year + 1): - created_count += 1 - - logger.info(f"Successfully created {created_count} birthday events") + else: + logger.info(f"Found {len(contacts)} contacts with birthdays") + created_count = 0 + current_year = datetime.now().year + for contact in contacts: + logger.info(f"Processing birthday for: {contact['name']} ({contact['birthday']})") + if caldav_client.create_birthday_event(contact, current_year): + created_count += 1 + if caldav_client.create_birthday_event(contact, current_year + 1): + created_count += 1 + logger.info(f"Successfully created {created_count} birthday events") + + if cardav_client.fetch_complete: + deleted = caldav_client.delete_orphans(contacts) + logger.info(f"Deleted {deleted} orphan birthday events") + else: + logger.warning( + f"Incomplete CardDAV fetch " + f"({cardav_client.vcard_fetched_ok}/{cardav_client.vcard_listed}); " + f"skipping orphan delete" + ) + return True except Exception as e: From e6b7090fa4fd7fa0ff31b51272b1f232cc7d92b0 Mon Sep 17 00:00:00 2001 From: HubEight <18333673+HubEight@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:01:31 +0000 Subject: [PATCH 13/15] Drop HubEight workflow tweaks from the upstream PR The Trivy image-ref fix is for this fork's GHCR job (HubEight vs hubeight, slashes in branch tags). It does not belong in a PR to anatosun/bdaysync. --- .github/workflows/docker.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d45e1db..aaf1232 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -123,16 +123,10 @@ jobs: security-events: write steps: - - name: Resolve image reference - run: | - repo="${GITHUB_REPOSITORY,,}" - tag="${GITHUB_REF_NAME//\//-}" - echo "TRIVY_IMAGE=${REGISTRY}/${repo}:${tag}" >> "$GITHUB_ENV" - - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: - image-ref: ${{ env.TRIVY_IMAGE }} + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} format: "sarif" output: "trivy-results.sarif" From 0a6cb29eb02ae9818b7271b4457da870702b6fe2 Mon Sep 17 00:00:00 2001 From: HubEight <18333673+HubEight@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:18:23 +0000 Subject: [PATCH 14/15] Only delete orphan birthday events after a truly complete fetch fetch_complete compared fetched vCards with listed ones, so a failed addressbook listing (HTTP error, exception, broken XML) was never counted and still yielded complete=True. Unparseable birthdays were counted as fetched, and an empty contact list with a complete fetch deleted every birthday event. - Clear fetch_complete on any listing, download or parse failure; _parse_vcard now raises on unreadable data and returns None only when there is no BDAY. Listing PROPFIND gets a timeout. - Restore main_sync returning False when no contacts were found, so orphan delete never runs with an empty set. - Match orphans on name slug plus month/day, so a changed birthday replaces the old event instead of leaving a duplicate. - Share the UID slug between event creation, lookup and orphan delete. - The retry docstring blamed IPv6: urllib3 tries every resolved address and IPv4 sorts first, so ENETUNREACH is only the last error. Add test_sync.py covering each case. Co-Authored-By: Claude Opus 5 --- bdaysync/caldav_client.py | 28 ++++---- bdaysync/cardav_client.py | 50 +++++--------- bdaysync/main.py | 35 +++++----- bdaysync/test_sync.py | 140 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 63 deletions(-) create mode 100644 bdaysync/test_sync.py diff --git a/bdaysync/caldav_client.py b/bdaysync/caldav_client.py index 8b4619e..bff3cd7 100644 --- a/bdaysync/caldav_client.py +++ b/bdaysync/caldav_client.py @@ -8,12 +8,17 @@ from typing import Dict, List, Optional import vobject import caldav - -BIRTHDAY_UID_RE = re.compile(r'^birthday-(.+)-(\d{8})$') from config import get_birthday_config logger = logging.getLogger(__name__) +BIRTHDAY_UID_RE = re.compile(r'^birthday-(.+)-\d{4}(\d{4})$') + + +def birthday_slug(name: str) -> str: + """Name part of a birthday event UID: birthday-{slug}-{YYYYMMDD}""" + return name.replace(' ', '-').lower() + class CalDAVClient: """Client for creating events in CalDAV server""" @@ -93,7 +98,7 @@ def create_birthday_event(self, contact: Dict, year: int = None) -> bool: return False # Create unique UID - event_uid = f"birthday-{name.replace(' ', '-').lower()}-{event_date.strftime('%Y%m%d')}" + event_uid = f"birthday-{birthday_slug(name)}-{event_date.strftime('%Y%m%d')}" # Create iCalendar event cal = vobject.iCalendar() @@ -168,15 +173,11 @@ def _format_reminder_message(self, name: str, days_before: int) -> str: return f"{name}'s birthday is in {days_before} days!" def delete_orphans(self, contacts: List[Dict]) -> int: - """Delete birthday-* events whose name slug is not in the current BDAY set.""" - wanted = {contact['name'].replace(' ', '-').lower() for contact in contacts} - events = self.calendar.events() - if not events: - logger.warning("No calendar events listed; skipping orphan delete") - return 0 + """Delete birthday-* events whose name and month/day match no current contact.""" + wanted = {(birthday_slug(c['name']), c['birthday'].strftime('%m%d')) for c in contacts} deleted = 0 - for ev in events: + for ev in self.calendar.events(): try: parsed = vobject.readOne(ev.data) if not hasattr(parsed, 'vevent') or not hasattr(parsed.vevent, 'uid'): @@ -185,8 +186,7 @@ def delete_orphans(self, contacts: List[Dict]) -> int: match = BIRTHDAY_UID_RE.match(uid) if not match: continue - slug = match.group(1) - if slug in wanted: + if match.groups() in wanted: continue logger.info(f"Deleting orphan birthday event: {uid}") ev.delete() @@ -229,7 +229,7 @@ def _find_existing_event(self, name: str, date) -> Optional: # Also check by UID pattern if hasattr(cal.vevent, 'uid'): uid = cal.vevent.uid.value - expected_uid = f"birthday-{name.replace(' ', '-').lower()}" + expected_uid = f"birthday-{birthday_slug(name)}" if uid.startswith(expected_uid): return event except Exception as e: @@ -254,7 +254,7 @@ def _find_existing_event(self, name: str, date) -> Optional: return event if hasattr(cal.vevent, 'uid'): uid = cal.vevent.uid.value - expected_uid = f"birthday-{name.replace(' ', '-').lower()}" + expected_uid = f"birthday-{birthday_slug(name)}" if uid.startswith(expected_uid): return event except Exception as e: diff --git a/bdaysync/cardav_client.py b/bdaysync/cardav_client.py index 2099980..ff64f1c 100644 --- a/bdaysync/cardav_client.py +++ b/bdaysync/cardav_client.py @@ -29,8 +29,6 @@ def __init__(self, server_url: str, username: str, password: str): # Discover addressbooks self.addressbook_urls = [] - self.vcard_listed = 0 - self.vcard_fetched_ok = 0 self.fetch_complete = False self._test_auth_and_discover() @@ -138,9 +136,8 @@ def _find_addressbooks(self, xml_response: str) -> List[str]: def get_contacts(self) -> List[Dict]: """Fetch all contacts from all discovered addressbooks""" all_contacts = [] - self.vcard_listed = 0 - self.vcard_fetched_ok = 0 - self.fetch_complete = False + # Cleared by any listing, download or parse failure. Orphan delete relies on it. + self.fetch_complete = True for addressbook_url in self.addressbook_urls: logger.info(f"Processing addressbook: {addressbook_url}") @@ -148,18 +145,12 @@ def get_contacts(self) -> List[Dict]: all_contacts.extend(contacts) logger.info(f"Found {len(contacts)} contacts with birthdays in this addressbook") - self.fetch_complete = ( - self.vcard_listed > 0 and self.vcard_fetched_ok == self.vcard_listed - ) - logger.info( - f"CardDAV fetch {self.vcard_fetched_ok}/{self.vcard_listed} vCards " - f"(complete={self.fetch_complete})" - ) + logger.info(f"CardDAV fetch complete: {self.fetch_complete}") logger.info(f"Total contacts with birthdays across all addressbooks: {len(all_contacts)}") return all_contacts def _http_get_retry(self, url: str, attempts: int = 3): - """GET with retries for transient connection errors (e.g. IPv6 unreachable).""" + """GET with retries for transient connection errors.""" last_error = None for i in range(1, attempts + 1): try: @@ -191,7 +182,8 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: logger.debug(f"Discovering resources in addressbook: {addressbook_url}") response = requests.request('PROPFIND', addressbook_url, - auth=self.auth, headers=headers, data=propfind_body) + auth=self.auth, headers=headers, data=propfind_body, + timeout=30) logger.debug(f"PROPFIND response status: {response.status_code}") @@ -205,8 +197,6 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: if not vcard_urls: logger.debug("No vCard URLs found in this addressbook") return contacts - - self.vcard_listed += len(vcard_urls) # Fetch each vCard for i, vcard_url in enumerate(vcard_urls): @@ -218,7 +208,6 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: logger.debug(f"vCard response status: {vcard_response.status_code}") if vcard_response.status_code == 200: - self.vcard_fetched_ok += 1 logger.debug(f"vCard content preview: {vcard_response.text[:200]}...") contact = self._parse_vcard(vcard_response.text) if contact: @@ -229,15 +218,19 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: logger.debug(f"No birthday found in vCard: {vcard_url}") else: logger.warning(f"Failed to fetch vCard {vcard_url}: {vcard_response.status_code}") + self.fetch_complete = False except Exception as e: logger.warning(f"Error processing vCard {vcard_url}: {e}") + self.fetch_complete = False continue else: logger.error(f"Failed to discover resources in {addressbook_url}: {response.status_code}") logger.error(f"Response: {response.text[:500]}") + self.fetch_complete = False except Exception as e: logger.error(f"Error fetching contacts from {addressbook_url}: {e}") + self.fetch_complete = False if logger.getEffectiveLevel() <= logging.DEBUG: import traceback logger.debug(traceback.format_exc()) @@ -247,12 +240,7 @@ def _get_contacts_from_addressbook(self, addressbook_url: str) -> List[Dict]: def _extract_vcard_urls(self, xml_response: str) -> List[str]: """Extract vCard URLs from PROPFIND response""" dav_namespace = 'DAV:' - - try: - root = ElementTree.fromstring(xml_response) - except ElementTree.ParseError as error: - logger.warning(f"Could not parse vCard discovery XML: {error}") - return [] + root = ElementTree.fromstring(xml_response) urls = [] for response in root.findall(f'{{{dav_namespace}}}response'): @@ -286,13 +274,12 @@ def _resolve_url(self, url: str) -> str: return f"{self.server_url.rstrip('/')}/{url.lstrip('/')}" def _parse_vcard(self, vcard_text: str) -> Optional[Dict]: - """Parse individual vCard""" + """Parse individual vCard. Returns None without BDAY, raises on unreadable data.""" try: # Clean up the vCard text vcard_text = vcard_text.strip() if not vcard_text.startswith('BEGIN:VCARD'): - logger.debug("Invalid vCard: doesn't start with BEGIN:VCARD") - return None + raise ValueError("Invalid vCard: doesn't start with BEGIN:VCARD") vcard = vobject.readOne(vcard_text) contact = {} @@ -337,12 +324,10 @@ def _parse_vcard(self, vcard_text: str) -> Optional[Dict]: month_day = bday_clean[2:] # Remove -- contact['birthday'] = datetime.strptime(f"2000-{month_day}", '%Y-%m-%d').date() else: - logger.warning(f"Unknown birthday format for {contact['name']}: {bday}") - return None + raise ValueError("unknown format") except ValueError as e: - logger.warning(f"Could not parse birthday for {contact['name']}: {bday} - {e}") - return None + raise ValueError(f"Could not parse birthday for {contact['name']}: {bday} - {e}") from e elif hasattr(bday, 'date'): contact['birthday'] = bday.date() @@ -364,7 +349,6 @@ def _parse_vcard(self, vcard_text: str) -> Optional[Dict]: logger.debug(f"No birthday found for contact: {contact['name']}") return None - except Exception as e: - logger.warning(f"Error parsing vCard: {e}") + except Exception: logger.debug(f"vCard content: {vcard_text[:500]}...") - return None + raise diff --git a/bdaysync/main.py b/bdaysync/main.py index 2c1d838..0353af5 100644 --- a/bdaysync/main.py +++ b/bdaysync/main.py @@ -98,27 +98,30 @@ def main_sync(): if not contacts: logger.warning("No contacts with birthdays found") - else: - logger.info(f"Found {len(contacts)} contacts with birthdays") - created_count = 0 - current_year = datetime.now().year - for contact in contacts: - logger.info(f"Processing birthday for: {contact['name']} ({contact['birthday']})") - if caldav_client.create_birthday_event(contact, current_year): - created_count += 1 - if caldav_client.create_birthday_event(contact, current_year + 1): - created_count += 1 - logger.info(f"Successfully created {created_count} birthday events") + return False + + logger.info(f"Found {len(contacts)} contacts with birthdays") + + # Create birthday events + created_count = 0 + current_year = datetime.now().year + + for contact in contacts: + logger.info(f"Processing birthday for: {contact['name']} ({contact['birthday']})") + if caldav_client.create_birthday_event(contact, current_year): + created_count += 1 + + # Also create for next year + if caldav_client.create_birthday_event(contact, current_year + 1): + created_count += 1 + + logger.info(f"Successfully created {created_count} birthday events") if cardav_client.fetch_complete: deleted = caldav_client.delete_orphans(contacts) logger.info(f"Deleted {deleted} orphan birthday events") else: - logger.warning( - f"Incomplete CardDAV fetch " - f"({cardav_client.vcard_fetched_ok}/{cardav_client.vcard_listed}); " - f"skipping orphan delete" - ) + logger.warning("Incomplete CardDAV fetch; skipping orphan delete") return True diff --git a/bdaysync/test_sync.py b/bdaysync/test_sync.py new file mode 100644 index 0000000..0e346d5 --- /dev/null +++ b/bdaysync/test_sync.py @@ -0,0 +1,140 @@ +""" +Safety tests for orphan deletion. Run from bdaysync/: python -m unittest test_sync +""" + +import logging +import unittest +from datetime import date +from unittest import mock + +import requests + +import caldav_client +import cardav_client +import main + +logging.disable(logging.CRITICAL) + +LISTING = ''' + + {ab} + {ab}a.vcf +''' + +VCARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Anna\r\n{bday}END:VCARD\r\n" + + +def response(status, text): + return mock.Mock(status_code=status, text=text) + + +def fetch(listings, vcard=VCARD.format(bday="BDAY:1990-01-02\r\n"), vcard_status=200): + """Run get_contacts against fake addressbooks; a listing is a response or an exception.""" + client = cardav_client.CardDAVClient.__new__(cardav_client.CardDAVClient) + client.server_url = 'https://dav.example' + client.auth = None + client.addressbook_urls = list(listings) + + def propfind(method, url, **kwargs): + listing = listings[url] + if isinstance(listing, Exception): + raise listing + return listing + + with mock.patch.object(cardav_client.requests, 'request', side_effect=propfind), \ + mock.patch.object(cardav_client.requests, 'get', return_value=response(vcard_status, vcard)): + contacts = client.get_contacts() + return contacts, client.fetch_complete + + +class CardDAVFetchComplete(unittest.TestCase): + OK = {'https://dav.example/ab1/': response(207, LISTING.format(ab='/ab1/'))} + + def test_all_vcards_fetched_is_complete(self): + contacts, complete = fetch(self.OK) + self.assertEqual(len(contacts), 1) + self.assertTrue(complete) + + def test_contact_without_birthday_keeps_fetch_complete(self): + _, complete = fetch(self.OK, vcard=VCARD.format(bday="")) + self.assertTrue(complete) + + def test_failed_addressbook_listing_is_incomplete(self): + failures = { + 'exception': requests.exceptions.ConnectionError('down'), + 'http error': response(503, 'unavailable'), + 'broken xml': response(207, ' Date: Tue, 15 Sep 2026 07:18:23 +0000 Subject: [PATCH 15/15] Add BIRTHDAY_DELETE_ORPHANS switch, off by default Orphan delete removes calendar data, so it is opt-in. Wired like the other BIRTHDAY_* settings: config, .env.template, docker-compose and README, and logged with the event configuration. Co-Authored-By: Claude Opus 5 --- .env.template | 3 +++ README.md | 1 + bdaysync/caldav_client.py | 2 ++ bdaysync/config.py | 3 ++- bdaysync/main.py | 11 ++++++----- bdaysync/test_sync.py | 17 +++++++++++++++++ docker-compose.yaml | 1 + 7 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.env.template b/.env.template index 2423abd..0d34aae 100644 --- a/.env.template +++ b/.env.template @@ -36,6 +36,9 @@ BIRTHDAY_EVENT_CATEGORY=Birthday # Whether to update existing events when templates change BIRTHDAY_UPDATE_EXISTING=true +# Delete birthday events whose contact or birthday is gone (only after a complete fetch) +BIRTHDAY_DELETE_ORPHANS=false + # ============================================================================ # OPTIONAL: Scheduling Configuration # ============================================================================ diff --git a/README.md b/README.md index 1dc5cf6..0d11e15 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ docker-compose up -d | `BIRTHDAY_REMINDER_MESSAGE` | `Reminder: {name}'s birthday is in {days} days!` | Reminder message template | | `BIRTHDAY_EVENT_CATEGORY` | `Birthday` | Event category | | `BIRTHDAY_UPDATE_EXISTING` | `true` | Update existing events | +| `BIRTHDAY_DELETE_ORPHANS` | `false` | Delete orphaned events | ### Logging & Debug diff --git a/bdaysync/caldav_client.py b/bdaysync/caldav_client.py index bff3cd7..3d60e26 100644 --- a/bdaysync/caldav_client.py +++ b/bdaysync/caldav_client.py @@ -63,6 +63,7 @@ def _load_config(self): self.reminder_template = config['reminder_template'] self.event_category = config['event_category'] self.update_existing = config['update_existing'] + self.delete_orphans_enabled = config['delete_orphans'] logger.info("Birthday event configuration:") logger.info(f" Title template: {self.event_title_template}") @@ -71,6 +72,7 @@ def _load_config(self): logger.info(f" Reminder message: {self.reminder_template}") logger.info(f" Category: {self.event_category}") logger.info(f" Update existing: {self.update_existing}") + logger.info(f" Delete orphans: {self.delete_orphans_enabled}") def create_birthday_event(self, contact: Dict, year: int = None) -> bool: """Create a birthday event for a contact""" diff --git a/bdaysync/config.py b/bdaysync/config.py index 6a2385f..fc344ce 100644 --- a/bdaysync/config.py +++ b/bdaysync/config.py @@ -81,7 +81,8 @@ def get_birthday_config(): 'reminder_days_str': os.getenv('BIRTHDAY_REMINDER_DAYS', '1'), 'reminder_template': os.getenv('BIRTHDAY_REMINDER_MESSAGE', 'Reminder: {name}\'s birthday is in {days} days!'), 'event_category': os.getenv('BIRTHDAY_EVENT_CATEGORY', 'Birthday'), - 'update_existing': os.getenv('BIRTHDAY_UPDATE_EXISTING', 'true').lower() == 'true' + 'update_existing': os.getenv('BIRTHDAY_UPDATE_EXISTING', 'true').lower() == 'true', + 'delete_orphans': os.getenv('BIRTHDAY_DELETE_ORPHANS', 'false').lower() == 'true' } def get_scheduler_config(): diff --git a/bdaysync/main.py b/bdaysync/main.py index 0353af5..e7c4239 100644 --- a/bdaysync/main.py +++ b/bdaysync/main.py @@ -117,11 +117,12 @@ def main_sync(): logger.info(f"Successfully created {created_count} birthday events") - if cardav_client.fetch_complete: - deleted = caldav_client.delete_orphans(contacts) - logger.info(f"Deleted {deleted} orphan birthday events") - else: - logger.warning("Incomplete CardDAV fetch; skipping orphan delete") + if caldav_client.delete_orphans_enabled: + if cardav_client.fetch_complete: + deleted = caldav_client.delete_orphans(contacts) + logger.info(f"Deleted {deleted} orphan birthday events") + else: + logger.warning("Incomplete CardDAV fetch; skipping orphan delete") return True diff --git a/bdaysync/test_sync.py b/bdaysync/test_sync.py index 0e346d5..09d42ae 100644 --- a/bdaysync/test_sync.py +++ b/bdaysync/test_sync.py @@ -11,6 +11,7 @@ import caldav_client import cardav_client +import config import main logging.disable(logging.CRITICAL) @@ -127,6 +128,22 @@ def test_deletes_removed_contacts_and_changed_dates_only(self): class MainSync(unittest.TestCase): + def test_orphan_delete_is_off_by_default(self): + with mock.patch.dict('os.environ', clear=True): + self.assertFalse(config.get_birthday_config()['delete_orphans']) + + def test_orphan_delete_follows_switch(self): + for enabled in (False, True): + with self.subTest(enabled=enabled), \ + mock.patch.object(main, 'CardDAVClient') as carddav, \ + mock.patch.object(main, 'CalDAVClient') as caldav: + carddav.return_value.get_contacts.return_value = [{'name': 'Anna', 'birthday': date(1990, 1, 2)}] + carddav.return_value.fetch_complete = True + caldav.return_value.delete_orphans_enabled = enabled + caldav.return_value.delete_orphans.return_value = 0 + self.assertTrue(main.main_sync()) + self.assertEqual(caldav.return_value.delete_orphans.called, enabled) + def test_no_contacts_fails_and_deletes_nothing(self): with mock.patch.object(main, 'CardDAVClient') as carddav, \ mock.patch.object(main, 'CalDAVClient') as caldav: diff --git a/docker-compose.yaml b/docker-compose.yaml index d4ddc2c..0986daf 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -25,6 +25,7 @@ services: BIRTHDAY_REMINDER_MESSAGE: "${BIRTHDAY_REMINDER_MESSAGE:-Reminder: {name}'s birthday is in {days} days!}" BIRTHDAY_EVENT_CATEGORY: "${BIRTHDAY_EVENT_CATEGORY:-Birthday}" BIRTHDAY_UPDATE_EXISTING: "${BIRTHDAY_UPDATE_EXISTING:-true}" + BIRTHDAY_DELETE_ORPHANS: "${BIRTHDAY_DELETE_ORPHANS:-false}" # Scheduling Configuration RUN_MODE: "${RUN_MODE:-daemon}" # Options: daemon, once