Files
2026-08-14 23:48:48 +02:00

310 lines
10 KiB
Markdown

# 🔄 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).
[![Dashboard](docs/screenshot1_thumbnail.png)](docs/screenshot1.png)
[![Contact detail](docs/screenshot2_thumbnail.png)](docs/screenshot2.png)
## 📋 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.json.example config/accounts.json
vim config/accounts.json
```
Trage für jede Apple-ID einen Eintrag mit eindeutigem `name`,
`apple_email`, `apple_app_password`, `authelia_user` und (optional)
`birthday_mail_to` ein. Optional kann pro Account eine `healthcheck_url`
konfiguriert werden, die nach jedem erfolgreichen Sync aufgerufen wird
(z.B. für Uptime-Monitoring). Diese Datei
bleibt lokal auf dem Host, sie ist in `.gitignore` ausgeschlossen und
wird nur als Volume in den Container gemountet.
Siehe `config/README.md` für eine vollständige Beschreibung der Felder.
## 🗄️ 3. Datenbank vorbereiten
Falls Datenbank und Benutzer noch nicht existieren, führe dieses Skript einmalig aus:
```bash
mysql -u root -p < sql/db-and-user.sql
```
## 🔧 4. Umgebungsvariablen konfigurieren
```
cp .env.example .env
vim .env
```
Trage mindestens `MARIADB_USER`, `MARIADB_PASSWORD` sowie (falls du den
Mailer nutzen willst) `SMTP_HOST` und `MAIL_FROM` ein. Die
Empfänger-Adresse wird pro Account in `accounts.json` unter
`birthday_mail_to` konfiguriert.
## 🐳 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 account, 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.
- Versendet pro Account mit gesetztem `birthday_mail_to` eine eigene
HTML-E-Mail mit stylisierten Geburtstagskarten und Links zur
Kontakt-Detailseite (falls `WEB_URL` gesetzt).
- Ü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
pro Account, solange bereits ein Eintrag in `birthday_mail_log` für
heute und diesen Account 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. Demo-Modus (Screenshot/Showcase)
Lokale Demo mit SQLite-Backend und Fake-Kontakten, ohne MariaDB,
Apple-IDs oder Docker. Zeigt Dashboard, Kontakt-Detailseite und
Gruppen-Übersicht mit farbigen UI-Avatar-Bildern.
### Starten
```bash
▶️ ./demo.sh
```
Das Script erstellt automatisch ein virtuelles Umfeld
(`.venv-demo/`), installiert die Dependencies und startet den
Server auf `0.0.0.0:8000`.
### 👀 Was angezeigt wird
- Dashboard mit 6 Kontakten, Geburtstagen der nächsten 7 Tage,
2 Gruppen ("Familie", "Arbeit") und成功stem Sync-Status
- Kontakt-Detailseite mit E-Mail, Telefon, Adresse, Foto
- Farbige Initialen-Avatare via ui-avatars.com
### 🔧 Technisches
- SQLite-Datenbank (`demo.db`) wird bei jedem Start frisch angelegt
- Kein `.env`, kein `accounts.json` nötig
- Templates und CSS werden aus `src/api/` wiederverwendet
- `.venv-demo/` und `demo.db` sind in `.gitignore` eingetragen
## ⚡ 11. CI/CD
- Jeder Push auf `main` baut automatisch ein neues Image und pusht es
nach `ghcr.io/<owner>/icloud-contacts-sync`.
- Ein separater Cleanup-Job behält jeweils nur die letzten 4 erfolgreich
gebauten, getaggten Images.
- Der Lint-Job nutzt `ruff==0.15.22` (gepint) mit der alten Default-Auswahl
`E4,E7,E9,F` (siehe `pyproject.toml`), so bleiben die Ergebnisse
reproduzierbar und unabhängig von neuen Ruff-Defaults.
- 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.
## 👥 Kontaktruppen
iCloud-Länder speichern Gruppen als eigene vCards mit
`X-ADDRESSBOOKSERVER-KIND:group`. Diese werden beim Sync automatisch
erkannt und separat in den Tabellen `groups` und `group_members`
gespeichert (nicht als Kontakte).
Gruppen sind über die API abrufbar:
- `GET /api/groups` — Alle Gruppen mit Member-Anzahl
- `GET /api/groups/{id}` — Gruppe mit aufgelösten Members
- `GET /api/groups/{id}/members` — Nur Members einer Gruppe
- `GET /api/contacts/{id}` — Enthält `groups`-Feld mit Gruppennamen
Wird eine Gruppe gelöscht, werden zugehörige Memberschaften automatisch
entfernt (`ON DELETE CASCADE`). Wird ein Mitglied-Kontakt gelöscht,
wird der Member-Eintrag in allen Gruppen ebenfalls entfernt (manueller
Cleanup im Sync-Code). Die Gruppe selbst bleibt erhalten.
### 🔄 Migration bei erstem Deploy
Bei Bestands-DBs lagen Gruppen bisher als normale Kontakte in der
`contacts`-Tabelle. Nach dem Deploy müssen diese einmalig bereinigt
werden:
1. Sync-Container stoppen: `docker compose stop icloud-contacts-sync`
2. Sync-State zurücksetzen: `DELETE FROM sync_state;` (erzwingt vollen Re-Sync)
3. Container neu starten: `docker compose start icloud-contacts-sync`
Beim nächsten Sync-Lauf werden alle vCards neu klassifiziziert —
Gruppen landen in `groups`, Kontakte bleiben in `contacts`.
## 🌐 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.
### 📍 Endpunkte (Routes)
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `GET` | `/` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) |
| `GET` | `/search` | Web-UI -- Suchfunktion, zeigt Kontakte des eingeloggten Users |
| `GET` | `/contacts/{id}` | Web-UI -- Detailseite eines einzelnen Kontakts (inkl. Gruppen) |
| `GET` | `/api/health` | Health Check (`{"status": "ok"}`), kein Login nötig |
| `GET` | `/api/contacts` | Kontaktsuche mit Pagination (`?q=...&limit=...&offset=...`) |
| `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen (inkl. `groups`-Feld) |
| `GET` | `/api/contacts/count` | Anzahl der Kontakte des eingeloggten Users |
| `GET` | `/api/contacts/birthdays/today` | Heutige Geburtstage |
| `GET` | `/api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (`?days=7`, Default 7) |
| `GET` | `/api/groups` | Gruppenliste mit Member-Anzahl |
| `GET` | `/api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members |
| `GET` | `/api/groups/{id}/members` | Members einer Gruppe (Kontaktdaten) |
| `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) |
Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung
über den `Remote-User`-Header. Nicht-Admins sehen nur die Daten ihres
eigenen Accounts.
### 🧪 API kurz testen (lokal auf der Docker-Host, mit Header simuliert)
```
curl -H "Remote-User: mmustermann" http://127.0.0.1:8000/api/contacts
```
## 📄 License
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)