13 KiB
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.ymlkonfigurierten Apple-IDs, jeweils isoliert mit eigenem sync-token und eigenemaccount-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 ausMAIL_SEND_HOURgeneriert.
3. Multi-User-Konfiguration
- Datei
config/accounts.json(gemountet, nicht im Image, nicht im Git, siehe.gitignore), Struktur:{ "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"] } nameist der interne, eindeutige Account-Bezeichner und wird 1:1 alsaccount-Spalte incontacts,sync_stateundsync_runsgespeichert.- 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-collectionmit dem zuletzt gespeichertensync-tokenausgeführt; der Server liefert nur geänderte, neue und gelöschte Kontakte seit diesem Token zurück. - Der neue
sync-tokenwird nach jedem erfolgreichen Lauf pro Account insync_stategespeichert. - Initialer Lauf: Existiert noch kein Token, wird einmalig ein
vollständiger Abruf per
addressbook-querydurchgeführt (sync_type = 'initial'insync_runs), anschließend wird der erstesync-tokengespeichert. - 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.accountzusä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 aktuellensync_token. sync_runserweitert umaccount,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 inMAIL_SEND_HOURkonfigurierten 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 überSMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD,MAIL_FROM,MAIL_TO. - Idempotenz: vor dem Versand wird
birthday_mail_logauf einen Eintrag für den heutigen Tag geprüft; existiert bereits einer, wird der Lauf ohne erneuten Versand beendet. - Feature-Flag
MAILER_ENABLEDerlaubt 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_URLgesetzt). 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_HOURUhr (fallsMAILER_ENABLED=true). - Initialer Sync sofort beim Container-Start.
- Sauberes Herunterfahren via SIGTERM/SIGINT.
- Läuft als non-root User (
syncuser, UID 10001). HEALTHCHECKprüft die Marker-Datei/tmp/last_sync_okdes letzten erfolgreichen Sync-Laufs.
9. CI/CD (GitHub Actions)
Unverändert gegenüber v1:
build-and-push.yml: Build und Push nachghcr.iobei Push aufmain, Tagslatestund Kurz-SHA.- Nachgelagerter
cleanup-Job überdataaxiom/ghcr-cleanup-action, behält die letzten 4 getaggten Images, löscht ungetaggte Artefakte,latestvon der Zählung ausgenommen. lint.yml: Ruff-Check auf Pull Requests.
10. Betrieb auf der Docker-Host
docker-compose.ymlmountetconfig/accounts.ymlread-only in den Container und übergibt DB- sowie SMTP-Zugangsdaten per.env.- Für private GHCR-Packages weiterhin einmaliger
docker login ghcr.iomit PAT (Scoperead: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.pyals auch das komplettesrc/api/-Package. docker-compose.ymldefiniert zwei Services aus demselben Image:icloud-contacts-sync: Standard-Entrypoint, startet den Python-Scheduler (scheduler.py) mit Sync- und Mailer-Intervallen.icloud-contacts-api: überschreibtcommandkomplett mituvicorn 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 aufcontacts,sync_runszu, 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_requestbereits authentifiziert hat und den Benutzernamen im HeaderRemote-Useran den Container weiterreicht. - Der Header-Name ist über
AUTH_REMOTE_USER_HEADERkonfigurierbar, falls dein Setup einen anderen Namen verwendet (z. B.X-Forwarded-User). api/auth.pyliest 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.jsonbekommt optional ein Feldauthelia_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 = %seingeschränkt. - Ein zusätzliches Top-Level-Feld
admins(Liste vonauthelia_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 auf127.0.0.1:8000weiterleitet und denRemote-User-Header setzt.