From d14690c5251fec9e03292b682779be15e5281c76 Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Tue, 4 Aug 2026 21:02:28 +0200 Subject: [PATCH] initial commit --- .env.example | 29 ++++ .github/workflows/build-and-push.yml | 65 +++++++ .github/workflows/lint.yml | 23 +++ .gitignore | 7 + Dockerfile | 44 +++++ README.md | 213 +++++++++++++++++++++++ SPEC.md | 247 +++++++++++++++++++++++++++ config/README.md | 29 ++++ config/accounts.json.example | 19 +++ docker-compose.yml | 63 +++++++ docker/crontab.default | 2 + docker/entrypoint.sh | 18 ++ docker/run-mailer.sh | 6 + docker/run-sync.sh | 7 + requirements.txt | 9 + sql/schema.sql | 66 +++++++ src/api/__init__.py | 0 src/api/auth.py | 42 +++++ src/api/main.py | 176 +++++++++++++++++++ src/api/schemas.py | 41 +++++ src/api/templates/index.html | 38 +++++ src/carddav_client.py | 148 ++++++++++++++++ src/config.py | 112 ++++++++++++ src/db.py | 110 ++++++++++++ src/mailer.py | 116 +++++++++++++ src/sync.py | 94 ++++++++++ src/vcard_parser.py | 86 ++++++++++ 27 files changed, 1810 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/build-and-push.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 SPEC.md create mode 100644 config/README.md create mode 100644 config/accounts.json.example create mode 100644 docker-compose.yml create mode 100644 docker/crontab.default create mode 100644 docker/entrypoint.sh create mode 100644 docker/run-mailer.sh create mode 100644 docker/run-sync.sh create mode 100644 requirements.txt create mode 100644 sql/schema.sql create mode 100644 src/api/__init__.py create mode 100644 src/api/auth.py create mode 100644 src/api/main.py create mode 100644 src/api/schemas.py create mode 100644 src/api/templates/index.html create mode 100644 src/carddav_client.py create mode 100644 src/config.py create mode 100644 src/db.py create mode 100644 src/mailer.py create mode 100644 src/sync.py create mode 100644 src/vcard_parser.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08d6617 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# MariaDB Zielverbindung +MARIADB_HOST=mariadb.internal +MARIADB_PORT=3306 +MARIADB_DATABASE=contacts +MARIADB_USER=contacts_sync +MARIADB_PASSWORD=change-me + +# Pfad zur Multi-User Accounts-Konfiguration (im Container gemounted) +ACCOUNTS_CONFIG_PATH=/app/config/accounts.json + +LOG_LEVEL=INFO + +# --- Geburtstags-Mailer --- +MAILER_ENABLED=true +SMTP_HOST=smtp.example.de +SMTP_PORT=587 +SMTP_USER=mailer@example.de +SMTP_PASSWORD=change-me +SMTP_USE_TLS=true +MAIL_FROM=contacts-sync@example.de +MAIL_TO=du@example.de +MAIL_SEND_HOUR=7 + +# --- Web-Ansicht / API (nur relevant für den zweiten Container) --- +# Name des Headers, den Authelia/dein Reverse-Proxy mit dem eingeloggten +# Benutzernamen befüllt (z.B. via nginx auth_request + proxy_set_header). +AUTH_REMOTE_USER_HEADER=Remote-User +API_HOST=0.0.0.0 +API_PORT=8000 diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..29f0824 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,65 @@ +name: Build and Push Image + +on: + push: + branches: [main] + workflow_dispatch: {} + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + cleanup: + needs: build-and-push + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - name: Keep only the last 4 successful images + uses: dataaxiom/ghcr-cleanup-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + package: icloud-contacts-sync + keep-n-tagged: 4 + delete-untagged: true + exclude-tags: latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..01ddc84 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,23 @@ +name: Lint + +on: + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install ruff + + - name: Run ruff + run: ruff check src/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80da1de --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +config/accounts.json +__pycache__/ +*.pyc +.DS_Store +*.log +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e8d29b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM python:3.12-slim + +ARG SUPERCRONIC_VERSION=v0.2.33 +ARG SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-amd64 +ARG SUPERCRONIC_SHA1SUM=71b0d58cc53f6bd72cf2f293e09e294b79c666d8 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && curl -fsSLO "$SUPERCRONIC_URL" \ + && echo "${SUPERCRONIC_SHA1SUM} supercronic-linux-amd64" | sha1sum -c - \ + && chmod +x supercronic-linux-amd64 \ + && mv supercronic-linux-amd64 /usr/local/bin/supercronic \ + && apt-get purge -y curl \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +# Enthält sowohl den Sync/Mailer-Code als auch die api/-App. +# Welcher Teil tatsächlich läuft, entscheidet der Command in docker-compose.yml, +# nicht das Image selbst -- ein Image, drei mögliche Rollen (sync, mailer, api). +COPY src/ /app/ +COPY docker/run-sync.sh /usr/local/bin/run-sync.sh +COPY docker/run-mailer.sh /usr/local/bin/run-mailer.sh +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY docker/crontab.default /etc/crontabs/app-crontab + +RUN chmod +x /usr/local/bin/run-sync.sh /usr/local/bin/run-mailer.sh /usr/local/bin/entrypoint.sh + +RUN mkdir -p /app/config \ + && useradd -m -u 10001 syncuser \ + && chown -R syncuser:syncuser /app /etc/crontabs +USER syncuser + +HEALTHCHECK --interval=5m --timeout=10s --start-period=30s \ + CMD test -f /tmp/last_sync_ok || exit 1 + +# Default-Entrypoint startet den Sync+Mailer-Cron-Modus. +# Der Web/API-Service in docker-compose.yml überschreibt "command" komplett +# mit uvicorn und läuft damit im selben Image in einer anderen Rolle. +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md index 361e337..5d24a4e 100644 --- a/README.md +++ b/README.md @@ -1 +1,214 @@ # icloud-contacts-sync + +Synct alle Kontakte mehrerer iCloud-Accounts per CardDAV Delta-Sync +(RFC 6578) automatisiert alle 15 Minuten in eine MariaDB-Datenbank +(`mariadb.internal`), plus täglichem Mailversand der heutigen +Geburtstage. Für den vollständigen technischen Hintergrund siehe +[SPEC.md](./SPEC.md). + +## Voraussetzungen + +- Eine oder mehrere Apple-IDs mit aktivierter Zwei-Faktor-Authentifizierung. +- Für jede Apple-ID ein app-spezifisches Passwort. +- Eine erreichbare MariaDB-Instanz mit vorbereiteter Datenbank. +- Ein SMTP-Relay (z. B. dein Mailprovider oder ein lokaler Relay) für + den Geburtstags-Mailer. +- Docker bzw. Docker Compose auf dem Zielhost (z. B. der Docker-Host auf + deinem Proxmox-Host). + +## 1. App-spezifische Passwörter erzeugen + +Für jede Apple-ID, die du syncen willst: + +1. Auf `account.apple.com` mit dieser Apple-ID anmelden. +2. Zu "Anmelden & Sicherheit" → "App-spezifische Passwörter" gehen. +3. Ein neues Passwort mit sprechendem Namen erzeugen (z. B. + `contacts-sync-debian`) und sofort sichern. + +## 2. Multi-User-Konfiguration anlegen + +``` +cp config/accounts.yml.example config/accounts.yml +vim config/accounts.yml +``` + +Trage für jede Apple-ID einen Eintrag mit eindeutigem `name`, +`apple_email` und `apple_app_password` ein. Diese Datei bleibt lokal +auf dem Host, sie ist in `.gitignore` ausgeschlossen und wird nur als +Volume in den Container gemountet. + +## 3. Datenbank vorbereiten + +``` +mysql -h mariadb.internal -u root -p < sql/schema.sql +``` + +Falls Datenbank und Benutzer noch nicht existieren, vorher z. B.: + +```sql +CREATE DATABASE contacts CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'contacts_sync'@'%' IDENTIFIED BY 'ein-sicheres-passwort'; +GRANT ALL PRIVILEGES ON contacts.* TO 'contacts_sync'@'%'; +FLUSH PRIVILEGES; +``` + +## 4. Umgebungsvariablen konfigurieren + +``` +cp .env.example .env +vim .env +``` + +Trage mindestens `MARIADB_USER`, `MARIADB_PASSWORD` sowie (falls du den +Mailer nutzen willst) `SMTP_HOST`, `MAIL_FROM` und `MAIL_TO` ein. + +## 5. Image beziehen + +``` +docker login ghcr.io -u DEIN_GITHUB_USER +``` + +Passe in `docker-compose.yml` den Image-Namen +(`ghcr.io/DEIN_GITHUB_USER/icloud-contacts-sync:latest`) auf deinen +tatsächlichen GitHub-Namespace an. + +## 6. Starten + +``` +docker compose up -d +``` + +Beim ersten Start wird für jeden Account automatisch ein vollständiger +initialer Sync ausgeführt (kein gespeicherter sync-token vorhanden). +Danach laufen alle 15 Minuten nur noch Delta-Syncs, die ausschließlich +Änderungen seit dem letzten Lauf übertragen. + +## 7. Logs und Status prüfen + +``` +docker logs -f icloud-contacts-sync +``` + +Sync-Historie je Account: + +```sql +SELECT account, sync_type, started_at, finished_at, status, + contacts_upserted, contacts_deleted +FROM sync_runs +ORDER BY started_at DESC +LIMIT 20; +``` + +Aktueller Delta-Sync-Token je Account: + +```sql +SELECT account, sync_token, updated_at FROM sync_state; +``` + +Versandhistorie der Geburtstagsmails: + +```sql +SELECT sent_date, contacts_count, sent_at FROM birthday_mail_log +ORDER BY sent_date DESC LIMIT 10; +``` + +## 8. Geburtstags-Mailer + +- Läuft automatisch täglich um die in `MAIL_SEND_HOUR` konfigurierte + Stunde (Default 7 Uhr) innerhalb desselben Containers. +- Über `MAILER_ENABLED=false` lässt sich der Mailer ganz abschalten, + ohne den Kontakt-Sync zu beeinträchtigen. +- Manueller Testlauf im laufenden Container: + ``` + docker exec -it icloud-contacts-sync python3 /app/mailer.py + ``` +- Ein zweiter manueller Lauf am selben Tag versendet keine zweite Mail, + solange bereits ein Eintrag in `birthday_mail_log` für heute existiert. + +## 9. Lokale Entwicklung (ohne Docker) + +``` +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cd src +python3 sync.py +python3 mailer.py +``` + +## 10. CI/CD + +- Jeder Push auf `main` baut automatisch ein neues Image und pusht es + nach `ghcr.io//icloud-contacts-sync`. +- Ein separater Cleanup-Job behält jeweils nur die letzten 4 erfolgreich + gebauten, getaggten Images. +- Details siehe SPEC.md, Abschnitt 9. + +## Bekannte Grenzen und geplante Erweiterungen + +- Delta-Sync reduziert die übertragene Datenmenge stark, ersetzt aber + keine vollständige Historie: ein gelöschter iCloud-Kontakt wird auch + aus MariaDB entfernt, ohne Archiv. +- Nur iCloud als Quelle, Google/Microsoft sind nicht Teil dieses Repos. +- Eine separate Web-Ansicht mit API ist als eigenständiges, + nachgelagertes Container-Projekt geplant, das nur lesend auf dieselbe + MariaDB zugreift (siehe SPEC.md, Abschnitt 11). + + +## 11. Web-Ansicht und API (interner Zugriff über Authelia) + +Läuft als zweiter Service aus demselben Image, aber mit anderem +Startbefehl, siehe `docker-compose.yml` (`icloud-contacts-api`). Die API +selbst hat kein eigenes Login, sie vertraut vollständig dem +vorgeschalteten Reverse-Proxy mit Authelia. + +### Voraussetzung: Reverse-Proxy mit Authelia + +Dein bestehender Reverse-Proxy muss für den Pfad/Host der +Web-Ansicht einen `auth_request` gegen Authelia ausführen und danach +den authentifizierten Benutzernamen im Header `Remote-User` an +`127.0.0.1:8000` weiterreichen. Ein typischer nginx-Ausschnitt: + +``` +location / { + auth_request /authelia/verify; + auth_request_set $user $upstream_http_remote_user; + proxy_set_header Remote-User $user; + proxy_pass http://127.0.0.1:8000; +} +``` + +Falls dein Setup den Benutzernamen unter einem anderen Header liefert, +passe `AUTH_REMOTE_USER_HEADER` in der `.env` entsprechend an. + +### Accounts-Mapping ergänzen + +In `config/accounts.json` bekommt jeder Account zusätzlich ein Feld +`authelia_user`: + +```json +{ + "accounts": [ + { "name": "markus", "apple_email": "...", "apple_app_password": "...", "authelia_user": "mmustermann" } + ], + "admins": ["mmustermann"] +} +``` + +Ein Benutzer aus `admins` sieht alle Accounts, alle anderen gemappten +Benutzer sehen ausschließlich ihren eigenen Account. + +### Starten + +``` +docker compose up -d icloud-contacts-api +``` + +Der Service läuft nur an `127.0.0.1:8000`, ein direkter externer +Zugriff ohne den Reverse-Proxy ist damit nicht möglich. + +### API kurz testen (lokal auf der Docker-Host, mit Header simuliert) + +``` +curl -H "Remote-User: mmustermann" http://127.0.0.1:8000/api/contacts +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e54f3df --- /dev/null +++ b/SPEC.md @@ -0,0 +1,247 @@ +# SPEC: iCloud Contacts Sync (v2) + +## 1. Zweck + +Automatisierter, wiederkehrender Delta-Sync mehrerer iCloud-Accounts +(CardDAV) in eine gemeinsame MariaDB-Instanz (`mariadb.internal`), +inklusive täglichem Mailversand für heutige Geburtstage. Ziel ist eine +vollständige, queryfähige Kopie aller Kontaktdaten mehrerer Apple-IDs +außerhalb des Apple-Ökosystems. + +## 2. Architektur + +``` ++-------------------+ CardDAV (HTTPS, Basic Auth, je Account) +----------------------+ +| icloud-contacts- | -------------------------------------------> | contacts.icloud.com | +| sync Container | +----------------------+ +| (Docker-Host auf | +| Proxmox-Host) | MySQL Protocol (TCP 3306) +| | -------------------------------------------> mariadb.internal +| | +| | SMTP (Port 587, STARTTLS) +| | -------------------------------------------> SMTP-Relay ++-------------------+ +``` + +- Ein Container verarbeitet sequenziell alle in `config/accounts.yml` + konfigurierten Apple-IDs, jeweils isoliert mit eigenem sync-token und + eigenem `account`-Feld in der Datenbank. +- Zwei unabhängige Cron-Jobs innerhalb desselben Containers: + Kontakt-Sync (alle 15 Minuten) und Geburtstags-Mailer (täglich, + konfigurierbare Uhrzeit). +- Zeitsteuerung über `supercronic`, Crontab wird beim Container-Start + dynamisch aus `MAIL_SEND_HOUR` generiert. + +## 3. Multi-User-Konfiguration + +- Datei `config/accounts.yml` (gemountet, nicht im Image, nicht im Git, + siehe `.gitignore`), Struktur: + ```yaml + accounts: + - name: markus + apple_email: markus@icloud.com + apple_app_password: "xxxx-xxxx-xxxx-xxxx" + - name: partner + apple_email: partner@icloud.com + apple_app_password: "yyyy-yyyy-yyyy-yyyy" + ``` +- `name` ist der interne, eindeutige Account-Bezeichner und wird 1:1 als + `account`-Spalte in `contacts`, `sync_state` und `sync_runs` + gespeichert. +- Jeder Account wird beim Sync-Lauf unabhängig verarbeitet: ein + Fehler bei einem Account (z. B. abgelaufenes App-Passwort) bricht den + Lauf für andere Accounts nicht ab. +- Die Datei liegt bewusst separat von `.env`, da sie mehrere + Credential-Sets enthält und sich unabhängig von der übrigen + Konfiguration versionieren/rotieren lässt. + +## 4. Delta-Sync über CardDAV sync-collection (RFC 6578) + +- Für jeden Account wird nach der Collection-Discovery ein + `REPORT sync-collection` mit dem zuletzt gespeicherten `sync-token` + ausgeführt; der Server liefert nur geänderte, neue und gelöschte + Kontakte seit diesem Token zurück. +- Der neue `sync-token` wird nach jedem erfolgreichen Lauf pro Account + in `sync_state` gespeichert. +- **Initialer Lauf**: Existiert noch kein Token, wird einmalig ein + vollständiger Abruf per `addressbook-query` durchgeführt + (`sync_type = 'initial'` in `sync_runs`), anschließend wird der erste + `sync-token` gespeichert. +- **Token-Ablauf**: iCloud-Tokens sind laut Beobachtung ca. 29 Tage + gültig. Lehnt der Server einen Token ab (`403 valid-sync-token`), + löscht der Client den gespeicherten Token und führt automatisch einen + vollen Re-Sync durch, ohne manuellen Eingriff. +- Gelöschte Kontakte werden über `404`-Status-Einträge in der + sync-collection-Antwort erkannt (Href-basiert) und gezielt aus + MariaDB entfernt, es findet kein pauschales Löschen aller Kontakte + mehr statt (Unterschied zu v1). +- Vorteil bei 2.000+ Kontakten: reguläre 15-Minuten-Läufe übertragen nur + die tatsächlichen Änderungen, nicht den kompletten Bestand. + +## 5. Datenmodell (MariaDB) + +Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1: + +- `contacts.account` zusätzliche Spalte, Eindeutigkeit jetzt über + `(account, uid)` statt `(uid, source)`, damit identische UIDs in + unterschiedlichen Apple-IDs nicht kollidieren. +- Neue Tabelle `sync_state`: ein Datensatz pro Account mit dem + aktuellen `sync_token`. +- `sync_runs` erweitert um `account`, `sync_type` + (`initial`/`delta`), `contacts_upserted`, `contacts_deleted`. +- Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag, an dem + erfolgreich eine Geburtstagsmail versendet wurde, verhindert + Doppelversand bei mehrfachem Container-Neustart am selben Tag. + +## 6. Geburtstags-Mailer + +- Eigenständiges Skript `src/mailer.py`, läuft im selben Container über + einen zweiten Cron-Eintrag, täglich zur in `MAIL_SEND_HOUR` + konfigurierten Stunde (Default 7 Uhr). +- Query: alle Kontakte über alle Accounts hinweg, deren `birthday` + (Monat/Tag) auf das heutige Datum fällt. +- Versand per SMTP mit STARTTLS (`smtplib`), Konfiguration über + `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `MAIL_FROM`, + `MAIL_TO`. +- Idempotenz: vor dem Versand wird `birthday_mail_log` auf einen + Eintrag für den heutigen Tag geprüft; existiert bereits einer, wird + der Lauf ohne erneuten Versand beendet. +- Feature-Flag `MAILER_ENABLED` erlaubt das komplette Deaktivieren ohne + Codeänderung. +- E-Mail-Inhalt aktuell reiner Text (Name, Alter, Account), HTML-Format + ist als spätere Erweiterung denkbar, aber nicht im Scope. + +## 7. Konfiguration (Umgebungsvariablen) + +| Variable | Pflicht | Beschreibung | +|-----------------------|---------|---------------------------------------------------| +| MARIADB_HOST | nein | Default: mariadb.internal | +| MARIADB_PORT | nein | Default: 3306 | +| MARIADB_DATABASE | nein | Default: contacts | +| MARIADB_USER | ja | DB-Benutzer mit Schreibrechten | +| MARIADB_PASSWORD | ja | Passwort des DB-Benutzers | +| ACCOUNTS_CONFIG_PATH | nein | Default: /app/config/accounts.yml | +| LOG_LEVEL | nein | Default: INFO | +| MAILER_ENABLED | nein | Default: true, deaktiviert Mailer bei false | +| SMTP_HOST | ja (Mailer) | SMTP-Relay-Host | +| SMTP_PORT | nein | Default: 587 | +| SMTP_USER | nein | leer, falls Relay ohne Auth | +| SMTP_PASSWORD | nein | leer, falls Relay ohne Auth | +| SMTP_USE_TLS | nein | Default: true | +| MAIL_FROM | ja (Mailer) | Absenderadresse | +| MAIL_TO | ja (Mailer) | Empfängeradresse(n) | +| MAIL_SEND_HOUR | nein | Default: 7, Stunde (0-23) für täglichen Mailversand | + +Secrets werden weiterhin als klassische Umgebungsvariablen übergeben, +mit Ausnahme der Multi-Account-Zugangsdaten, die aus `accounts.yml` +gelesen werden (per Volume-Mount, nicht im Image, nicht im Git). + +## 8. Container-Image + +- Basis: `python:3.12-slim`, Zeitsteuerung über `supercronic`. +- Zwei Cron-Einträge im dynamisch generierten Crontab: Sync (`*/15`) + und Mailer (`0 * * *`). +- Läuft als non-root User (`syncuser`, UID 10001). +- `HEALTHCHECK` prüft weiterhin die Marker-Datei des letzten + erfolgreichen Sync-Laufs. + +## 9. CI/CD (GitHub Actions) + +Unverändert gegenüber v1: + +- `build-and-push.yml`: Build und Push nach `ghcr.io` bei Push auf + `main`, Tags `latest` und Kurz-SHA. +- Nachgelagerter `cleanup`-Job über `dataaxiom/ghcr-cleanup-action`, + behält die letzten 4 getaggten Images, löscht ungetaggte Artefakte, + `latest` von der Zählung ausgenommen. +- `lint.yml`: Ruff-Check auf Pull Requests. + +## 10. Betrieb auf der Docker-Host + +- `docker-compose.yml` mountet `config/accounts.yml` read-only in den + Container und übergibt DB- sowie SMTP-Zugangsdaten per `.env`. +- Für private GHCR-Packages weiterhin einmaliger `docker login ghcr.io` + mit PAT (Scope `read:packages`) nötig. + +## 11. Geplante spätere Erweiterungen (nicht in diesem Repo) + +- **Web-Ansicht + API**: separates Container-Image (z. B. FastAPI + + einfaches Frontend), liest ausschließlich aus derselben MariaDB, + schreibt nicht in die `contacts`-Tabelle, um Konflikte mit dem + Sync-Container zu vermeiden. Kann als eigenes Repository nach + demselben Muster (Dockerfile, GitHub Actions, ghcr.io) aufgebaut + werden. +- **Weitere Quellen**: Google Contacts und Microsoft 365 nach + demselben Account-Muster (eigene `source`-Werte, eigene + Sync-Strategie je Anbieter-API). +- **HTML-Mails, mehrere Empfänger je Kontakt, Vorlauf-Erinnerungen** + (z. B. "in 3 Tagen") sind funktional einfach nachrüstbar, aktuell + aber nicht Teil des Scopes. + + +## 12. Web-Ansicht und API (v3, im selben Repo/Image) + +Ursprünglich als separates Projekt geplant, jetzt bewusst ins selbe +Repository und Image integriert, da Codebasis (Config, DB-Layer) ohnehin +geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft. + +### 12.1 Ein Image, mehrere Rollen + +- Das Dockerfile bleibt unverändert eines für alle Zwecke: es enthält + sowohl `src/sync.py`, `src/mailer.py` als auch das komplette + `src/api/`-Package. +- `docker-compose.yml` definiert zwei Services aus demselben Image: + - `icloud-contacts-sync`: Standard-Entrypoint, startet `supercronic` + mit Sync- und Mailer-Cron (unverändert zu v2). + - `icloud-contacts-api`: überschreibt `command` komplett mit + `uvicorn api.main:app`, ignoriert den Cron-Entrypoint des Images. +- Beide Services teilen sich dieselbe MariaDB und dieselbe + `config/accounts.json`, der API-Service greift ausschließlich lesend + auf `contacts`, `sync_runs` zu, schreibt nichts. + +### 12.2 Authelia-Integration + +- Die API selbst implementiert kein Login. Sie geht davon aus, dass ein + vorgeschalteter Reverse-Proxy (nginx/Traefik) mit Authelia via + `auth_request` bereits authentifiziert hat und den Benutzernamen im + Header `Remote-User` an den Container weiterreicht. +- Der Header-Name ist über `AUTH_REMOTE_USER_HEADER` konfigurierbar, + falls dein Setup einen anderen Namen verwendet (z. B. + `X-Forwarded-User`). +- `api/auth.py` liest diesen Header per FastAPI-`Header`-Dependency; ist + er nicht gesetzt, antwortet die API mit 401, da dies bedeutet, dass + der Zugriff nicht über den Authelia-geschützten Pfad erfolgte. + +### 12.3 Accounts-Mapping (`authelia_user`) + +- Jeder Account in `accounts.json` bekommt optional ein Feld + `authelia_user`, das den Authelia-Benutzernamen mit dem internen + Account-Namen verknüpft. +- Beim Request wird der Remote-User-Header gegen dieses Mapping + aufgelöst: der Benutzer sieht ausschließlich die Kontakte seines + eigenen Accounts, alle Queries werden serverseitig mit + `WHERE account = %s` eingeschränkt. +- Ein zusätzliches Top-Level-Feld `admins` (Liste von + `authelia_user`-Werten) erlaubt bestimmten Benutzern uneingeschränkten + Zugriff auf alle Accounts, z. B. für dich als Betreiber. +- Ist ein eingeloggter Authelia-User weder gemappt noch Admin, antwortet + die API mit 403. + +### 12.4 Endpunkte + +| Endpunkt | Beschreibung | +|---|---| +| `GET /` | Einfache HTML-Übersicht (Jinja2-Template), zeigt Kontakte des zugeordneten Accounts | +| `GET /api/health` | Health-Check ohne Auth-Anforderung | +| `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` | +| `GET /api/contacts/{id}` | Einzelner Kontakt | +| `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) | +| `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) | + +### 12.5 Netzwerkkontext + +- Der API-Container bindet den Port nur an `127.0.0.1:8000`, ist also + auf der Docker-Host selbst nicht von außen erreichbar. +- Externer Zugriff läuft über deinen bestehenden Reverse-Proxy mit + Authelia im internen Netzwerk (`deinem lokalen Netz`), der intern auf + `127.0.0.1:8000` weiterleitet und den `Remote-User`-Header setzt. diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..c5e0d59 --- /dev/null +++ b/config/README.md @@ -0,0 +1,29 @@ +# config/accounts.json + +Diese Datei enthält alle Apple-ID-Zugangsdaten sowie das Mapping auf +Authelia-Benutzernamen. Sie liegt bewusst außerhalb von Git (siehe +`.gitignore`) und wird als Volume in beide Container (Sync und +Web/API) gemountet. + +Felder pro Account: + +- `name`: interner, eindeutiger Account-Bezeichner (Spalte `account` + in MariaDB). +- `apple_email` / `apple_app_password`: CardDAV-Zugangsdaten, nur vom + Sync-Container genutzt. +- `authelia_user`: Benutzername, wie ihn Authelia im + `Remote-User`-Header an die Web-Ansicht/API durchreicht. Dieser Wert + bestimmt, welchen Account ein eingeloggter Benutzer in der + Web-Ansicht sieht. + +Optionales Feld `admins` (Liste von `authelia_user`-Werten): diese +Benutzer sehen in der Web-Ansicht/API die Kontakte aller Accounts, +nicht nur ihren eigenen. + +Kopiere `accounts.json.example` nach `accounts.json` und trage echte +Werte ein: + +``` +cp accounts.json.example accounts.json +vim accounts.json +``` diff --git a/config/accounts.json.example b/config/accounts.json.example new file mode 100644 index 0000000..4286559 --- /dev/null +++ b/config/accounts.json.example @@ -0,0 +1,19 @@ +{ + "accounts": [ + { + "name": "markus", + "apple_email": "markus@icloud.com", + "apple_app_password": "xxxx-xxxx-xxxx-xxxx", + "authelia_user": "mmustermann" + }, + { + "name": "partner", + "apple_email": "partner@icloud.com", + "apple_app_password": "yyyy-yyyy-yyyy-yyyy", + "authelia_user": "partner.user" + } + ], + "admins": [ + "mmustermann" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1537fb3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +version: "3.9" + +services: + icloud-contacts-sync: + image: ghcr.io/DEIN_GITHUB_USER/icloud-contacts-sync:latest + container_name: icloud-contacts-sync + restart: unless-stopped + environment: + MARIADB_HOST: "${MARIADB_HOST:-mariadb.internal}" + MARIADB_PORT: "${MARIADB_PORT:-3306}" + MARIADB_DATABASE: "${MARIADB_DATABASE:-contacts}" + MARIADB_USER: "${MARIADB_USER}" + MARIADB_PASSWORD: "${MARIADB_PASSWORD}" + ACCOUNTS_CONFIG_PATH: "/app/config/accounts.json" + LOG_LEVEL: "${LOG_LEVEL:-INFO}" + MAILER_ENABLED: "${MAILER_ENABLED:-true}" + SMTP_HOST: "${SMTP_HOST}" + SMTP_PORT: "${SMTP_PORT:-587}" + SMTP_USER: "${SMTP_USER}" + SMTP_PASSWORD: "${SMTP_PASSWORD}" + SMTP_USE_TLS: "${SMTP_USE_TLS:-true}" + MAIL_FROM: "${MAIL_FROM}" + MAIL_TO: "${MAIL_TO}" + MAIL_SEND_HOUR: "${MAIL_SEND_HOUR:-7}" + volumes: + - ./config/accounts.json:/app/config/accounts.json:ro + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + icloud-contacts-api: + image: ghcr.io/DEIN_GITHUB_USER/icloud-contacts-sync:latest + container_name: icloud-contacts-api + restart: unless-stopped + # Gleiches Image wie oben, aber anderer Command -> startet die FastAPI-App + # statt des Cron-Entrypoints. Läuft NUR lesend gegen MariaDB. + command: ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] + working_dir: /app + environment: + MARIADB_HOST: "${MARIADB_HOST:-mariadb.internal}" + MARIADB_PORT: "${MARIADB_PORT:-3306}" + MARIADB_DATABASE: "${MARIADB_DATABASE:-contacts}" + MARIADB_USER: "${MARIADB_USER}" + MARIADB_PASSWORD: "${MARIADB_PASSWORD}" + ACCOUNTS_CONFIG_PATH: "/app/config/accounts.json" + LOG_LEVEL: "${LOG_LEVEL:-INFO}" + AUTH_REMOTE_USER_HEADER: "${AUTH_REMOTE_USER_HEADER:-Remote-User}" + API_HOST: "${API_HOST:-0.0.0.0}" + API_PORT: "${API_PORT:-8000}" + volumes: + - ./config/accounts.json:/app/config/accounts.json:ro + ports: + - "127.0.0.1:8000:8000" + # Nur lokal an localhost gebunden: der eigentliche externe Zugriff läuft + # über deinen Reverse-Proxy mit Authelia (auth_request), der intern auf + # diesen Port weiterleitet und den Remote-User-Header setzt. + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/docker/crontab.default b/docker/crontab.default new file mode 100644 index 0000000..8fbfeb0 --- /dev/null +++ b/docker/crontab.default @@ -0,0 +1,2 @@ +*/15 * * * * /usr/local/bin/run-sync.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 +0 7 * * * /usr/local/bin/run-mailer.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..4635b2c --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +MAIL_HOUR="${MAIL_SEND_HOUR:-7}" + +echo "Starte icloud-contacts-sync Container" +echo "Sync-Schedule: */15 * * * * (alle 15 Minuten, Delta-Sync)" +echo "Mailer-Schedule: taeglich um ${MAIL_HOUR} Uhr (falls MAILER_ENABLED=true)" + +# Crontab wird zur Laufzeit generiert, damit MAIL_SEND_HOUR konfigurierbar bleibt. +cat > /etc/crontabs/app-crontab <> /proc/1/fd/1 2>> /proc/1/fd/2 +0 ${MAIL_HOUR} * * * /usr/local/bin/run-mailer.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 +EOF + +/usr/local/bin/run-sync.sh || echo "Initialer Sync fehlgeschlagen, Cron laeuft trotzdem weiter" + +exec supercronic -json /etc/crontabs/app-crontab diff --git a/docker/run-mailer.sh b/docker/run-mailer.sh new file mode 100644 index 0000000..289b67c --- /dev/null +++ b/docker/run-mailer.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e +cd /app +echo "$(date -Is) - Mailer-Lauf gestartet" +python3 /app/mailer.py +echo "$(date -Is) - Mailer-Lauf beendet" diff --git a/docker/run-sync.sh b/docker/run-sync.sh new file mode 100644 index 0000000..975ab47 --- /dev/null +++ b/docker/run-sync.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e +cd /app +echo "$(date -Is) - Sync-Lauf gestartet" +python3 /app/sync.py +echo "$(date -Is) - Sync-Lauf beendet" +touch /tmp/last_sync_ok diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c7517d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +requests==2.32.3 +vobject==0.9.6.1 +PyMySQL==1.1.1 +lxml==5.3.0 +python-dotenv==1.0.1 +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +jinja2==3.1.4 +pydantic==2.9.2 diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..3e2d3d5 --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,66 @@ +-- Schema v2: Multi-User Delta-Sync + Geburtstags-Mailer +-- Speichert alle vCard-Felder strukturiert (JSON für Mehrfachwerte) plus rohen vCard-Text als Fallback. + +CREATE TABLE IF NOT EXISTS contacts ( + id INT AUTO_INCREMENT PRIMARY KEY, + account VARCHAR(100) NOT NULL, -- Account-Name aus accounts.yml + uid VARCHAR(255) NOT NULL, + etag VARCHAR(255) NULL, + full_name VARCHAR(512) NULL, + given_name VARCHAR(255) NULL, + family_name VARCHAR(255) NULL, + middle_name VARCHAR(255) NULL, + prefix VARCHAR(50) NULL, + suffix VARCHAR(50) NULL, + nickname VARCHAR(255) NULL, + organization VARCHAR(255) NULL, + job_title VARCHAR(255) NULL, + department VARCHAR(255) NULL, + birthday DATE NULL, + anniversary DATE NULL, + notes TEXT NULL, + photo_base64 LONGTEXT NULL, + emails JSON NULL, + phones JSON NULL, + addresses JSON NULL, + urls JSON NULL, + social_profiles JSON NULL, + related_names JSON NULL, + categories JSON NULL, + raw_vcard LONGTEXT NOT NULL, + source VARCHAR(50) NOT NULL DEFAULT 'icloud', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + last_synced_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + sync_run_id VARCHAR(64) NULL, + UNIQUE KEY uq_contacts_account_uid (account, uid), + KEY idx_contacts_birthday_md (birthday) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Speichert pro Account den letzten CardDAV sync-token (RFC 6578) für Delta-Sync. +CREATE TABLE IF NOT EXISTS sync_state ( + account VARCHAR(100) PRIMARY KEY, + sync_token TEXT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS sync_runs ( + id VARCHAR(64) PRIMARY KEY, + account VARCHAR(100) NOT NULL, + sync_type ENUM('initial', 'delta') NOT NULL DEFAULT 'delta', + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP NULL, + status ENUM('running', 'success', 'failed') NOT NULL DEFAULT 'running', + contacts_upserted INT NULL, + contacts_deleted INT NULL, + error_message TEXT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Protokoll der Geburtstags-Mails, verhindert Doppelversand am selben Tag. +CREATE TABLE IF NOT EXISTS birthday_mail_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + sent_date DATE NOT NULL, + contacts_count INT NOT NULL, + sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_birthday_mail_date (sent_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/auth.py b/src/api/auth.py new file mode 100644 index 0000000..0b47d1e --- /dev/null +++ b/src/api/auth.py @@ -0,0 +1,42 @@ +"""Authelia-Integration: liest den vom Reverse-Proxy weitergereichten +Remote-User-Header und mappt ihn per accounts.json auf einen internen +Account-Namen. Kein eigenes Login, Authelia übernimmt die eigentliche +Authentifizierung vorgeschaltet.""" +from fastapi import Header, HTTPException, status + +from config import Config + + +def get_current_user(remote_user: str | None = Header(default=None, alias="Remote-User")) -> str: + """Extrahiert den Authelia-Benutzernamen aus dem konfigurierten Header. + Der Header-Name ist über AUTH_REMOTE_USER_HEADER konfigurierbar, FastAPI + bindet hier auf den Default 'Remote-User', siehe Hinweis in README.md + falls du einen anderen Header-Namen in Authelia/nginx konfiguriert hast.""" + if not remote_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Kein Remote-User-Header vom Reverse-Proxy erhalten. " + "Läuft die Anwendung hinter Authelia mit korrektem auth_request-Setup?", + ) + return remote_user + + +def resolve_account_for_user(authelia_user: str) -> tuple[str | None, bool]: + """Gibt (account_name, is_admin) zurück. + account_name ist None, wenn der User als Admin auf ALLE Accounts zugreifen darf. + Ist der User weder gemappt noch Admin, wird eine 403 geworfen.""" + admins = Config.load_admin_users() + user_map = Config.load_authelia_user_map() + + is_admin = authelia_user in admins + + if is_admin: + return None, True + + account_name = user_map.get(authelia_user) + if not account_name: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Authelia-Benutzer '{authelia_user}' ist keinem Account in accounts.json zugeordnet.", + ) + return account_name, False diff --git a/src/api/main.py b/src/api/main.py new file mode 100644 index 0000000..1c7e07c --- /dev/null +++ b/src/api/main.py @@ -0,0 +1,176 @@ +"""FastAPI-App für die interne Web-Ansicht/API der iCloud-Kontakte. + +Läuft im selben Image wie der Sync-Container, wird aber über einen +eigenen Docker-Compose-Service mit abweichendem Startbefehl gestartet +(uvicorn statt sync/mailer). Zugriff ausschließlich über einen +vorgeschalteten Reverse-Proxy mit Authelia, der den eingeloggten +Benutzernamen im Remote-User-Header mitschickt.""" +import json +import logging +from datetime import date + +from fastapi import FastAPI, Depends, Query, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from config import Config +import db +from api.auth import get_current_user, resolve_account_for_user +from api.schemas import ContactListResponse, ContactOut, SyncRunOut + +logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("api") + +app = FastAPI(title="iCloud Contacts Sync – Interne API", version="1.0.0") +templates = Jinja2Templates(directory="api/templates") + + +def _row_to_contact_out(row: dict) -> dict: + row = dict(row) + for field in ["emails", "phones", "addresses", "urls", "categories"]: + raw = row.get(field) + row[field] = json.loads(raw) if raw else [] + row["updated_at"] = str(row["updated_at"]) + return row + + +def _account_filter_clause(account_name: str | None) -> tuple[str, list]: + if account_name is None: + return "", [] + return "WHERE account = %s", [account_name] + + +@app.get("/api/health") +def health(): + return {"status": "ok"} + + +@app.get("/api/contacts", response_model=ContactListResponse) +def list_contacts( + request: Request, + q: str | None = Query(default=None, description="Freitextsuche über Name, Organisation, E-Mail"), + limit: int = Query(default=50, le=200), + offset: int = Query(default=0, ge=0), + current_user: str = Depends(get_current_user), +): + account_name, is_admin = resolve_account_for_user(current_user) + + with db.get_connection() as conn: + where_clause, params = _account_filter_clause(account_name) + search_clause = "" + if q: + search_op = "AND" if where_clause else "WHERE" + search_clause = f" {search_op} (full_name LIKE %s OR organization LIKE %s OR emails LIKE %s)" + like = f"%{q}%" + params.extend([like, like, like]) + + with conn.cursor() as cur: + cur.execute(f"SELECT COUNT(*) AS total FROM contacts {where_clause}{search_clause}", params) + total = cur.fetchone()["total"] + + cur.execute( + f"""SELECT id, account, uid, full_name, given_name, family_name, organization, + job_title, birthday, notes, emails, phones, addresses, urls, categories, updated_at + FROM contacts {where_clause}{search_clause} + ORDER BY full_name + LIMIT %s OFFSET %s""", + params + [limit, offset], + ) + rows = cur.fetchall() + + items = [_row_to_contact_out(r) for r in rows] + return {"total": total, "items": items} + + +@app.get("/api/contacts/{contact_id}", response_model=ContactOut) +def get_contact(contact_id: int, current_user: str = Depends(get_current_user)): + account_name, is_admin = resolve_account_for_user(current_user) + + with db.get_connection() as conn: + where_clause, params = _account_filter_clause(account_name) + id_clause = "AND id = %s" if where_clause else "WHERE id = %s" + with conn.cursor() as cur: + cur.execute( + f"""SELECT id, account, uid, full_name, given_name, family_name, organization, + job_title, birthday, notes, emails, phones, addresses, urls, categories, updated_at + FROM contacts {where_clause} {id_clause}""", + params + [contact_id], + ) + row = cur.fetchone() + + if not row: + return {} + return _row_to_contact_out(row) + + +@app.get("/api/contacts/birthdays/today", response_model=list[ContactOut]) +def birthdays_today(current_user: str = Depends(get_current_user)): + account_name, is_admin = resolve_account_for_user(current_user) + today = date.today() + + with db.get_connection() as conn: + where_clause, params = _account_filter_clause(account_name) + month_day_clause = "AND MONTH(birthday) = %s AND DAY(birthday) = %s" if where_clause \ + else "WHERE MONTH(birthday) = %s AND DAY(birthday) = %s" + with conn.cursor() as cur: + cur.execute( + f"""SELECT id, account, uid, full_name, given_name, family_name, organization, + job_title, birthday, notes, emails, phones, addresses, urls, categories, updated_at + FROM contacts {where_clause} {month_day_clause} + ORDER BY full_name""", + params + [today.month, today.day], + ) + rows = cur.fetchall() + + return [_row_to_contact_out(r) for r in rows] + + +@app.get("/api/sync-runs", response_model=list[SyncRunOut]) +def list_sync_runs(current_user: str = Depends(get_current_user)): + account_name, is_admin = resolve_account_for_user(current_user) + + with db.get_connection() as conn: + where_clause, params = _account_filter_clause(account_name) + with conn.cursor() as cur: + cur.execute( + f"""SELECT id, account, sync_type, started_at, finished_at, status, + contacts_upserted, contacts_deleted, error_message + FROM sync_runs {where_clause} + ORDER BY started_at DESC + LIMIT 50""", + params, + ) + rows = cur.fetchall() + + for r in rows: + r["started_at"] = str(r["started_at"]) + r["finished_at"] = str(r["finished_at"]) if r["finished_at"] else None + return rows + + +@app.get("/", response_class=HTMLResponse) +def web_index(request: Request, current_user: str = Depends(get_current_user)): + account_name, is_admin = resolve_account_for_user(current_user) + + with db.get_connection() as conn: + where_clause, params = _account_filter_clause(account_name) + with conn.cursor() as cur: + cur.execute( + f"""SELECT id, full_name, organization, birthday, account + FROM contacts {where_clause} + ORDER BY full_name + LIMIT 200""", + params, + ) + rows = cur.fetchall() + + return templates.TemplateResponse( + "index.html", + { + "request": request, + "current_user": current_user, + "is_admin": is_admin, + "account_name": account_name or "alle Accounts", + "contacts": rows, + }, + ) diff --git a/src/api/schemas.py b/src/api/schemas.py new file mode 100644 index 0000000..cc27f02 --- /dev/null +++ b/src/api/schemas.py @@ -0,0 +1,41 @@ +from datetime import date +from pydantic import BaseModel + + +class ContactOut(BaseModel): + id: int + account: str + uid: str + full_name: str | None + given_name: str | None + family_name: str | None + organization: str | None + job_title: str | None + birthday: date | None + notes: str | None + emails: list + phones: list + addresses: list + urls: list + categories: list + updated_at: str + + class Config: + from_attributes = True + + +class ContactListResponse(BaseModel): + total: int + items: list[ContactOut] + + +class SyncRunOut(BaseModel): + id: str + account: str + sync_type: str + started_at: str + finished_at: str | None + status: str + contacts_upserted: int | None + contacts_deleted: int | None + error_message: str | None diff --git a/src/api/templates/index.html b/src/api/templates/index.html new file mode 100644 index 0000000..ffe7557 --- /dev/null +++ b/src/api/templates/index.html @@ -0,0 +1,38 @@ + + + + + Kontakte – {{ account_name }} + + + +

Kontakte

+
+ Angemeldet als {{ current_user }} + {% if is_admin %}(Admin, sieht alle Accounts){% else %}(Account: {{ account_name }}){% endif %} + · {{ contacts|length }} Kontakte (max. 200 angezeigt) +
+ + + {% if is_admin %}{% endif %} + + + {% for c in contacts %} + + + + + {% if is_admin %}{% endif %} + + {% endfor %} + +
NameOrganisationGeburtstagAccount
{{ c.full_name or "-" }}{{ c.organization or "-" }}{{ c.birthday or "-" }}{{ c.account }}
+ + diff --git a/src/carddav_client.py b/src/carddav_client.py new file mode 100644 index 0000000..92d5c86 --- /dev/null +++ b/src/carddav_client.py @@ -0,0 +1,148 @@ +""" +CardDAV-Client für iCloud (RFC 6352) mit Delta-Sync über sync-collection (RFC 6578). + +Ablauf pro Account: +1. PROPFIND auf die Basis-URL -> current-user-principal ermitteln +2. PROPFIND auf das Principal -> addressbook-home-set ermitteln +3. PROPFIND auf das Addressbook-Home -> tatsächliche Addressbook-Collection finden +4. REPORT sync-collection mit gespeichertem sync-token -> nur Änderungen abrufen + (leerer sync-token beim allerersten Lauf -> voller initialer Abruf) + +iCloud-Tokens sind laut Google/Apple-Doku ca. 29 Tage gültig; läuft ein Token ab, +antwortet der Server mit 403 valid-sync-token, dann fällt der Client automatisch +auf einen vollständigen Re-Sync zurück. +""" +import logging +from xml.etree import ElementTree as ET +from urllib.parse import urljoin + +import requests + +logger = logging.getLogger(__name__) + +ICLOUD_BASE_URL = "https://contacts.icloud.com/" + +NS = { + "d": "DAV:", + "card": "urn:ietf:params:xml:ns:carddav", +} + + +class SyncTokenInvalid(Exception): + """Wird geworfen, wenn der Server den gespeicherten sync-token nicht mehr akzeptiert.""" + + +class CardDAVClient: + def __init__(self, base_url: str, username: str, password: str, timeout: int = 30): + self.base_url = base_url + self.auth = (username, password) + self.timeout = timeout + self.session = requests.Session() + + def _request(self, method: str, url: str, body: str, depth: str = "0"): + headers = {"Depth": depth, "Content-Type": "application/xml; charset=utf-8"} + resp = self.session.request( + method, url, data=body, headers=headers, auth=self.auth, timeout=self.timeout, + ) + if resp.status_code == 403 and "valid-sync-token" in resp.text: + raise SyncTokenInvalid("sync-token vom Server abgelehnt") + resp.raise_for_status() + return ET.fromstring(resp.content) + + def discover_principal(self) -> str: + body = """ + + + """ + root = self._request("PROPFIND", self.base_url, body) + href = root.find(".//d:current-user-principal/d:href", NS) + if href is None: + raise RuntimeError("current-user-principal nicht gefunden") + return self._absolute(href.text) + + def discover_addressbook_home(self, principal_url: str) -> str: + body = """ + + + """ + root = self._request("PROPFIND", principal_url, body) + href = root.find(".//card:addressbook-home-set/d:href", NS) + if href is None: + raise RuntimeError("addressbook-home-set nicht gefunden") + return self._absolute(href.text) + + def discover_addressbook_collection(self, home_url: str) -> str: + body = """ + + + """ + root = self._request("PROPFIND", home_url, body, depth="1") + for response in root.findall("d:response", NS): + resourcetype = response.find(".//d:resourcetype", NS) + if resourcetype is not None and any(child.tag.endswith("addressbook") for child in resourcetype): + href = response.find("d:href", NS) + if href is not None: + return self._absolute(href.text) + raise RuntimeError("Keine addressbook-Collection gefunden") + + def sync_collection(self, collection_url: str, sync_token: str | None): + """ + Führt REPORT sync-collection aus. Gibt (changed_or_new_vcards, deleted_hrefs, new_sync_token) zurück. + changed_or_new_vcards: list[str] roher vCard-Text + deleted_hrefs: list[str] Hrefs von gelöschten Kontakten (status 404) + """ + token_element = f"{sync_token}" if sync_token else "" + body = f""" + + {token_element} + 1 + + """ + root = self._request("REPORT", collection_url, body, depth="1") + + vcards, deleted_hrefs = [], [] + for response in root.findall("d:response", NS): + status_el = response.find(".//d:status", NS) + status_text = status_el.text if status_el is not None else "" + href_el = response.find("d:href", NS) + href = href_el.text if href_el is not None else None + + if "404" in status_text: + if href: + deleted_hrefs.append(href) + continue + + data = response.find(".//card:address-data", NS) + if data is not None and data.text: + vcards.append(data.text) + + new_token_el = root.find("d:sync-token", NS) + new_token = new_token_el.text if new_token_el is not None else None + return vcards, deleted_hrefs, new_token + + def fetch_all_vcards(self, collection_url: str) -> list[str]: + """Fallback für den allerersten, vollen Abruf über addressbook-query.""" + body = """ + + + + """ + root = self._request("REPORT", collection_url, body, depth="1") + vcards = [] + for response in root.findall("d:response", NS): + data = response.find(".//card:address-data", NS) + if data is not None and data.text: + vcards.append(data.text) + return vcards + + def discover_collection(self) -> str: + principal = self.discover_principal() + home = self.discover_addressbook_home(principal) + collection = self.discover_addressbook_collection(home) + logger.info("Addressbook-Collection gefunden: %s", collection) + return collection + + def _absolute(self, href: str) -> str: + if href.startswith("http"): + return href + return urljoin(self.base_url, href) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..db7724d --- /dev/null +++ b/src/config.py @@ -0,0 +1,112 @@ +import os +import json +from dotenv import load_dotenv + +load_dotenv() + +ICLOUD_BASE_URL = "https://contacts.icloud.com/" + + +class Account: + def __init__(self, name: str, apple_email: str, apple_app_password: str, authelia_user: str | None): + self.name = name + self.apple_email = apple_email + self.apple_app_password = apple_app_password + self.authelia_user = authelia_user + + +class Config: + MARIADB_HOST = os.environ.get("MARIADB_HOST", "mariadb.internal") + MARIADB_PORT = int(os.environ.get("MARIADB_PORT", "3306")) + MARIADB_DATABASE = os.environ.get("MARIADB_DATABASE", "contacts") + MARIADB_USER = os.environ.get("MARIADB_USER", "") + MARIADB_PASSWORD = os.environ.get("MARIADB_PASSWORD", "") + + ACCOUNTS_CONFIG_PATH = os.environ.get("ACCOUNTS_CONFIG_PATH", "/app/config/accounts.json") + + LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") + SOURCE_NAME = "icloud" + + MAILER_ENABLED = os.environ.get("MAILER_ENABLED", "false").lower() == "true" + SMTP_HOST = os.environ.get("SMTP_HOST", "") + SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) + SMTP_USER = os.environ.get("SMTP_USER", "") + SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") + SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true" + MAIL_FROM = os.environ.get("MAIL_FROM", "") + MAIL_TO = os.environ.get("MAIL_TO", "") + + # Authelia liefert den eingeloggten Benutzer per Header, der vom + # vorgeschalteten nginx/traefik als Remote-User weitergereicht wird. + AUTH_REMOTE_USER_HEADER = os.environ.get("AUTH_REMOTE_USER_HEADER", "Remote-User") + + API_HOST = os.environ.get("API_HOST", "0.0.0.0") + API_PORT = int(os.environ.get("API_PORT", "8000")) + + @classmethod + def validate_db(cls): + missing = [n for n, v in [ + ("MARIADB_USER", cls.MARIADB_USER), + ("MARIADB_PASSWORD", cls.MARIADB_PASSWORD), + ] if not v] + if missing: + raise RuntimeError(f"Fehlende Umgebungsvariablen: {', '.join(missing)}") + + @classmethod + def validate_mailer(cls): + missing = [n for n, v in [ + ("SMTP_HOST", cls.SMTP_HOST), + ("MAIL_FROM", cls.MAIL_FROM), + ("MAIL_TO", cls.MAIL_TO), + ] if not v] + if missing: + raise RuntimeError(f"Fehlende Mailer-Umgebungsvariablen: {', '.join(missing)}") + + @classmethod + def _load_raw_accounts_config(cls) -> dict: + if not os.path.exists(cls.ACCOUNTS_CONFIG_PATH): + raise RuntimeError( + f"Accounts-Konfiguration nicht gefunden: {cls.ACCOUNTS_CONFIG_PATH}. " + f"Kopiere config/accounts.json.example nach config/accounts.json." + ) + with open(cls.ACCOUNTS_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + @classmethod + def load_accounts(cls) -> list[Account]: + data = cls._load_raw_accounts_config() + accounts_raw = data.get("accounts", []) + if not accounts_raw: + raise RuntimeError("accounts.json enthält keine Accounts") + + seen_names = set() + accounts = [] + for entry in accounts_raw: + name = entry.get("name") + email = entry.get("apple_email") + pwd = entry.get("apple_app_password") + authelia_user = entry.get("authelia_user") + if not all([name, email, pwd]): + raise RuntimeError(f"Unvollständiger Account-Eintrag: {entry}") + if name in seen_names: + raise RuntimeError(f"Account-Name '{name}' ist nicht eindeutig") + seen_names.add(name) + accounts.append(Account(name, email, pwd, authelia_user)) + return accounts + + @classmethod + def load_admin_users(cls) -> set[str]: + data = cls._load_raw_accounts_config() + return set(data.get("admins", [])) + + @classmethod + def load_authelia_user_map(cls) -> dict[str, str]: + """Gibt {authelia_user: account_name} zurück, genutzt von der Web-Ansicht/API.""" + accounts = cls.load_accounts() + mapping = {} + for acc in accounts: + if acc.authelia_user: + if acc.authelia_user in mapping: + raise RuntimeError(f"authelia_user '{acc.authelia_user}' ist mehreren Accounts zugeordnet") + mapping[acc.authelia_user] = acc.name + return mapping diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..f0b4820 --- /dev/null +++ b/src/db.py @@ -0,0 +1,110 @@ +"""MariaDB-Anbindung: Delta-Sync-Strategie (Upsert für Änderungen, gezieltes Löschen für Removals).""" +import logging +import uuid +from contextlib import contextmanager + +import pymysql +from pymysql.cursors import DictCursor + +from config import Config + +logger = logging.getLogger(__name__) + + +@contextmanager +def get_connection(): + conn = pymysql.connect( + host=Config.MARIADB_HOST, port=Config.MARIADB_PORT, + user=Config.MARIADB_USER, password=Config.MARIADB_PASSWORD, + database=Config.MARIADB_DATABASE, cursorclass=DictCursor, autocommit=False, + ) + try: + yield conn + finally: + conn.close() + + +def get_sync_token(conn, account: str) -> str | None: + with conn.cursor() as cur: + cur.execute("SELECT sync_token FROM sync_state WHERE account = %s", (account,)) + row = cur.fetchone() + return row["sync_token"] if row else None + + +def save_sync_token(conn, account: str, sync_token: str): + with conn.cursor() as cur: + cur.execute( + """INSERT INTO sync_state (account, sync_token) VALUES (%s, %s) + ON DUPLICATE KEY UPDATE sync_token = %s""", + (account, sync_token, sync_token), + ) + conn.commit() + + +def clear_sync_token(conn, account: str): + with conn.cursor() as cur: + cur.execute("DELETE FROM sync_state WHERE account = %s", (account,)) + conn.commit() + + +def start_sync_run(conn, account: str, sync_type: str) -> str: + run_id = str(uuid.uuid4()) + with conn.cursor() as cur: + cur.execute( + "INSERT INTO sync_runs (id, account, sync_type, status) VALUES (%s, %s, %s, 'running')", + (run_id, account, sync_type), + ) + conn.commit() + return run_id + + +def finish_sync_run(conn, run_id: str, status: str, upserted: int = None, deleted: int = None, error_message: str = None): + with conn.cursor() as cur: + cur.execute( + """UPDATE sync_runs SET status=%s, contacts_upserted=%s, contacts_deleted=%s, + error_message=%s, finished_at=NOW() WHERE id=%s""", + (status, upserted, deleted, error_message, run_id), + ) + conn.commit() + + +def upsert_contacts(conn, contacts: list[dict], run_id: str): + if not contacts: + return + with conn.cursor() as cur: + for c in contacts: + c["sync_run_id"] = run_id + cols = list(c.keys()) + placeholders = ", ".join(["%s"] * len(cols)) + update_clause = ", ".join(f"{col}=VALUES({col})" for col in cols if col not in ("account", "uid")) + sql = ( + f"INSERT INTO contacts ({', '.join(cols)}) VALUES ({placeholders}) " + f"ON DUPLICATE KEY UPDATE {update_clause}" + ) + cur.execute(sql, list(c.values())) + conn.commit() + + +def delete_contacts_by_href_uids(conn, account: str, uids: list[str]): + if not uids: + return + with conn.cursor() as cur: + placeholders = ", ".join(["%s"] * len(uids)) + cur.execute( + f"DELETE FROM contacts WHERE account = %s AND uid IN ({placeholders})", + [account] + uids, + ) + conn.commit() + + +def replace_all_contacts_for_account(conn, account: str, contacts: list[dict], run_id: str): + """Voller Re-Sync für einen Account (initialer Lauf oder Recovery nach ungültigem sync-token).""" + with conn.cursor() as cur: + cur.execute("DELETE FROM contacts WHERE account = %s", (account,)) + for c in contacts: + c["sync_run_id"] = run_id + cols = ", ".join(c.keys()) + placeholders = ", ".join(["%s"] * len(c)) + cur.execute(f"INSERT INTO contacts ({cols}) VALUES ({placeholders})", list(c.values())) + conn.commit() + logger.info("Voller Re-Sync für Account %s abgeschlossen: %d Kontakte", account, len(contacts)) diff --git a/src/mailer.py b/src/mailer.py new file mode 100644 index 0000000..3f2bb4b --- /dev/null +++ b/src/mailer.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Sendet eine tägliche E-Mail mit allen heutigen Geburtstagskindern aus der +contacts-Tabelle (über alle Accounts hinweg). Wird per Cron einmal täglich +um MAIL_SEND_HOUR aufgerufen. Verhindert Doppelversand am selben Tag über +die Tabelle birthday_mail_log.""" +import logging +import smtplib +import sys +from datetime import date +from email.message import EmailMessage + +from config import Config +import db + +logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("mailer") + + +def fetch_todays_birthdays(conn) -> list[dict]: + today = date.today() + with conn.cursor() as cur: + cur.execute( + """SELECT account, full_name, birthday + FROM contacts + WHERE birthday IS NOT NULL + AND MONTH(birthday) = %s + AND DAY(birthday) = %s + ORDER BY full_name""", + (today.month, today.day), + ) + return cur.fetchall() + + +def already_sent_today(conn) -> bool: + today = date.today() + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM birthday_mail_log WHERE sent_date = %s", (today,)) + return cur.fetchone() is not None + + +def log_sent(conn, count: int): + today = date.today() + with conn.cursor() as cur: + cur.execute( + "INSERT INTO birthday_mail_log (sent_date, contacts_count) VALUES (%s, %s)", + (today, count), + ) + conn.commit() + + +def build_message(birthdays: list[dict]) -> EmailMessage: + today = date.today() + msg = EmailMessage() + msg["From"] = Config.MAIL_FROM + msg["To"] = Config.MAIL_TO + + if not birthdays: + msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): keine" + msg.set_content("Heute hat niemand aus deinen Kontakten Geburtstag.") + return msg + + msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {len(birthdays)}" + lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""] + for b in birthdays: + age = today.year - b["birthday"].year + lines.append(f"- {b['full_name']} (wird {age}, Account: {b['account']})") + msg.set_content("\n".join(lines)) + return msg + + +def send_message(msg: EmailMessage): + if Config.SMTP_USE_TLS: + with smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT) as server: + server.starttls() + if Config.SMTP_USER: + server.login(Config.SMTP_USER, Config.SMTP_PASSWORD) + server.send_message(msg) + else: + with smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT) as server: + if Config.SMTP_USER: + server.login(Config.SMTP_USER, Config.SMTP_PASSWORD) + server.send_message(msg) + + +def main() -> int: + if not Config.MAILER_ENABLED: + logger.info("Mailer ist deaktiviert (MAILER_ENABLED=false), überspringe Lauf") + return 0 + + try: + Config.validate_db() + Config.validate_mailer() + except RuntimeError as exc: + logger.error(str(exc)) + return 1 + + with db.get_connection() as conn: + if already_sent_today(conn): + logger.info("Geburtstagsmail wurde heute bereits versendet, überspringe") + return 0 + + birthdays = fetch_todays_birthdays(conn) + msg = build_message(birthdays) + try: + send_message(msg) + except Exception: + logger.exception("Versand der Geburtstagsmail fehlgeschlagen") + return 1 + + log_sent(conn, len(birthdays)) + logger.info("Geburtstagsmail versendet: %d Kontakte", len(birthdays)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/sync.py b/src/sync.py new file mode 100644 index 0000000..54c8a0d --- /dev/null +++ b/src/sync.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Einstiegspunkt für einen Sync-Lauf über alle konfigurierten Accounts. +Für jeden Account wird ein Delta-Sync per CardDAV sync-collection (RFC 6578) +durchgeführt. Beim allerersten Lauf eines Accounts (kein gespeicherter +sync-token) sowie nach einem vom Server abgelehnten Token erfolgt ein +vollständiger Re-Sync.""" +import logging +import sys + +from config import Config +from carddav_client import CardDAVClient, ICLOUD_BASE_URL, SyncTokenInvalid +from vcard_parser import parse_vcard +import db + +logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("sync") + + +def sync_account(conn, account, href_to_uid_cache: dict): + client = CardDAVClient(ICLOUD_BASE_URL, account.apple_email, account.apple_app_password) + collection_url = client.discover_collection() + + stored_token = db.get_sync_token(conn, account.name) + sync_type = "delta" if stored_token else "initial" + run_id = db.start_sync_run(conn, account.name, sync_type) + + try: + if not stored_token: + logger.info("[%s] Kein sync-token vorhanden, führe initialen Full-Sync aus", account.name) + raw_vcards = client.fetch_all_vcards(collection_url) + contacts = [c for c in (parse_vcard(v, account.name) for v in raw_vcards) if c] + db.replace_all_contacts_for_account(conn, account.name, contacts, run_id) + _, _, new_token = client.sync_collection(collection_url, None) + if new_token: + db.save_sync_token(conn, account.name, new_token) + db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=0) + logger.info("[%s] Initialer Sync abgeschlossen: %d Kontakte", account.name, len(contacts)) + return + + try: + changed_vcards, deleted_hrefs, new_token = client.sync_collection(collection_url, stored_token) + except SyncTokenInvalid: + logger.warning("[%s] sync-token vom Server abgelehnt, führe vollen Re-Sync aus", account.name) + db.clear_sync_token(conn, account.name) + raw_vcards = client.fetch_all_vcards(collection_url) + contacts = [c for c in (parse_vcard(v, account.name) for v in raw_vcards) if c] + db.replace_all_contacts_for_account(conn, account.name, contacts, run_id) + _, _, new_token = client.sync_collection(collection_url, None) + if new_token: + db.save_sync_token(conn, account.name, new_token) + db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=0) + logger.info("[%s] Re-Sync abgeschlossen: %d Kontakte", account.name, len(contacts)) + return + + contacts = [c for c in (parse_vcard(v, account.name) for v in changed_vcards) if c] + db.upsert_contacts(conn, contacts, run_id) + + deleted_uids = [href.rstrip("/").rsplit("/", 1)[-1].replace(".vcf", "") for href in deleted_hrefs] + db.delete_contacts_by_href_uids(conn, account.name, deleted_uids) + + if new_token: + db.save_sync_token(conn, account.name, new_token) + + db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=len(deleted_uids)) + logger.info( + "[%s] Delta-Sync abgeschlossen: %d geändert/neu, %d gelöscht", + account.name, len(contacts), len(deleted_uids), + ) + except Exception as exc: + logger.exception("[%s] Sync-Lauf %s fehlgeschlagen", account.name, run_id) + db.finish_sync_run(conn, run_id, "failed", error_message=str(exc)) + raise + + +def main() -> int: + try: + Config.validate_db() + accounts = Config.load_accounts() + except RuntimeError as exc: + logger.error(str(exc)) + return 1 + + exit_code = 0 + with db.get_connection() as conn: + for account in accounts: + try: + sync_account(conn, account, {}) + except Exception: + exit_code = 1 + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/vcard_parser.py b/src/vcard_parser.py new file mode 100644 index 0000000..c00235c --- /dev/null +++ b/src/vcard_parser.py @@ -0,0 +1,86 @@ +"""Parst rohen vCard-Text in ein flaches dict, passend zum contacts-Schema.""" +import json +import logging + +import vobject + +logger = logging.getLogger(__name__) + + +def _get(vcard, attr, default=None): + return getattr(vcard, attr).value if hasattr(vcard, attr) else default + + +def parse_vcard(raw_text: str, account: str) -> dict | None: + try: + vcard = vobject.readOne(raw_text) + except Exception as exc: + logger.warning("vCard konnte nicht geparst werden: %s", exc) + return None + + uid = _get(vcard, "uid") + if not uid: + logger.warning("vCard ohne UID übersprungen") + return None + + n = getattr(vcard, "n", None) + given_name = n.value.given if n else None + family_name = n.value.family if n else None + middle_name = n.value.additional if n else None + prefix = n.value.prefix if n else None + suffix = n.value.suffix if n else None + + emails = [{"type": ",".join(e.type_paramlist) if e.type_paramlist else "other", "value": e.value} + for e in getattr(vcard, "email_list", [])] + phones = [{"type": ",".join(t.type_paramlist) if t.type_paramlist else "other", "value": t.value} + for t in getattr(vcard, "tel_list", [])] + + addresses = [] + for a in getattr(vcard, "adr_list", []): + v = a.value + addresses.append({ + "type": ",".join(a.type_paramlist) if a.type_paramlist else "other", + "street": v.street, "city": v.city, "region": v.region, + "zip": v.code, "country": v.country, + }) + + urls = [{"type": ",".join(u.type_paramlist) if u.type_paramlist else "other", "value": u.value} + for u in getattr(vcard, "url_list", [])] + + categories = [c.strip() for c in vcard.categories.value] if hasattr(vcard, "categories") else [] + + birthday = str(vcard.bday.value)[:10] if hasattr(vcard, "bday") else None + + org = None + if hasattr(vcard, "org"): + org_val = vcard.org.value + org = org_val[0] if isinstance(org_val, list) else org_val + + return { + "account": account, + "uid": uid, + "etag": None, + "full_name": _get(vcard, "fn"), + "given_name": given_name, + "family_name": family_name, + "middle_name": middle_name, + "prefix": prefix, + "suffix": suffix, + "nickname": _get(vcard, "nickname"), + "organization": org, + "job_title": _get(vcard, "title"), + "department": None, + "birthday": birthday, + "anniversary": None, + "notes": _get(vcard, "note"), + "photo_base64": None, + "emails": json.dumps(emails, ensure_ascii=False), + "phones": json.dumps(phones, ensure_ascii=False), + "addresses": json.dumps(addresses, ensure_ascii=False), + "urls": json.dumps(urls, ensure_ascii=False), + "social_profiles": json.dumps([], ensure_ascii=False), + "related_names": json.dumps([], ensure_ascii=False), + "categories": json.dumps(categories, ensure_ascii=False), + "raw_vcard": raw_text, + "source": "icloud", + }