# 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.json` (gemountet, nicht im Image, nicht im Git, siehe `.gitignore`), Struktur: ```json { "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": "pmustermann" } ], "admins": ["mmustermann"] } ``` - `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 (Default: `false`). - E-Mail-Inhalt: HTML-E-Mail mit stylisierten Geburtstagskarten (Name, Alter, Account, Link zur Kontakt-Detailseite falls `WEB_URL` gesetzt). Zusätzlich reiner Text-Alternative als Fallback. ## 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.json | | LOG_LEVEL | nein | Default: INFO | | MAILER_ENABLED | nein | Default: false, aktiviert Mailer bei true | | 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 | | WEB_URL | nein | Web-URL für Links in Geburtstags-Mails (z.B. https://kontakte.example.de) | | AUTH_REMOTE_USER_HEADER | nein | Default: Remote-User, Header-Name für Authelia-User | | API_HOST | nein | Default: 0.0.0.0, Bindungs-Adresse des API-Services | | API_PORT | nein | Default: 8000, Port des API-Services | Secrets werden weiterhin als klassische Umgebungsvariablen übergeben, mit Ausnahme der Multi-Account-Zugangsdaten, die aus `accounts.json` gelesen werden (per Volume-Mount, nicht im Image, nicht im Git). ## 8. Container-Image - Basis: `python:3.12-slim`. - Python-basierter Scheduler (`src/scheduler.py`) als PID 1 im Container: - Keine externe Cron-Abhängigkeit (kein supercronic nötig). - Sync: alle 15 Minuten (`SYNC_INTERVAL_MINUTES`). - Mailer: täglich um `MAIL_SEND_HOUR` Uhr (falls `MAILER_ENABLED=true`). - Initialer Sync sofort beim Container-Start. - Sauberes Herunterfahren via SIGTERM/SIGINT. - Läuft als non-root User (`syncuser`, UID 10001). - `HEALTHCHECK` prüft die Marker-Datei `/tmp/last_sync_ok` 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). - **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 den Python-Scheduler (`scheduler.py`) mit Sync- und Mailer-Intervallen. - `icloud-contacts-api`: überschreibt `command` komplett mit `uvicorn api.main:app`, ignoriert den Scheduler-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 /` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) | | `GET /search` | HTML-Übersicht mit Suchfunktion, zeigt Kontakte des zugeordneten Accounts | | `GET /contacts/{id}` | HTML-Detailseite eines einzelnen Kontakts (Jinja2-Template) | | `GET /api/health` | Health-Check ohne Auth-Anforderung | | `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` | | `GET /api/contacts/{id}` | Einzelner Kontakt (JSON) | | `GET /api/contacts/count` | Anzahl der Kontakte des zugeordneten Accounts | | `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) | | `GET /api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (Parameter `days`, Default 7) | | `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.