initial commit

This commit is contained in:
2026-08-04 21:02:28 +02:00
parent fc1bbe2a85
commit d14690c525
27 changed files with 1810 additions and 0 deletions
+29
View File
@@ -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
+65
View File
@@ -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
+23
View File
@@ -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/
+7
View File
@@ -0,0 +1,7 @@
.env
config/accounts.json
__pycache__/
*.pyc
.DS_Store
*.log
.vscode/
+44
View File
@@ -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"]
+213
View File
@@ -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/<owner>/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
```
+247
View File
@@ -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 <MAIL_SEND_HOUR> * * *`).
- 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.
+29
View File
@@ -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
```
+19
View File
@@ -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"
]
}
+63
View File
@@ -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"
+2
View File
@@ -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
+18
View File
@@ -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 <<EOF
*/15 * * * * /usr/local/bin/run-sync.sh >> /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
+6
View File
@@ -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"
+7
View File
@@ -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
+9
View File
@@ -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
+66
View File
@@ -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;
View File
+42
View File
@@ -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
+176
View File
@@ -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,
},
)
+41
View File
@@ -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
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Kontakte {{ account_name }}</title>
<style>
body { font-family: sans-serif; margin: 2rem; background: #111; color: #eee; }
h1 { font-size: 1.4rem; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { border-bottom: 1px solid #333; padding: 0.4rem 0.6rem; text-align: left; }
th { background: #1c1c1c; }
.meta { color: #999; font-size: 0.85rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<h1>Kontakte</h1>
<div class="meta">
Angemeldet als <strong>{{ current_user }}</strong>
{% if is_admin %}(Admin, sieht alle Accounts){% else %}(Account: {{ account_name }}){% endif %}
· {{ contacts|length }} Kontakte (max. 200 angezeigt)
</div>
<table>
<thead>
<tr><th>Name</th><th>Organisation</th><th>Geburtstag</th>{% if is_admin %}<th>Account</th>{% endif %}</tr>
</thead>
<tbody>
{% for c in contacts %}
<tr>
<td>{{ c.full_name or "-" }}</td>
<td>{{ c.organization or "-" }}</td>
<td>{{ c.birthday or "-" }}</td>
{% if is_admin %}<td>{{ c.account }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
+148
View File
@@ -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 = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:current-user-principal/></d:prop>
</d:propfind>"""
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 = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop><card:addressbook-home-set/></d:prop>
</d:propfind>"""
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 = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:resourcetype/><d:displayname/></d:prop>
</d:propfind>"""
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"<d:sync-token>{sync_token}</d:sync-token>" if sync_token else "<d:sync-token/>"
body = f"""<?xml version="1.0" encoding="utf-8" ?>
<d:sync-collection xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
{token_element}
<d:sync-level>1</d:sync-level>
<d:prop><d:getetag/><card:address-data/></d:prop>
</d:sync-collection>"""
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 = """<?xml version="1.0" encoding="utf-8" ?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop><d:getetag/><card:address-data/></d:prop>
<card:filter/>
</card:addressbook-query>"""
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)
+112
View File
@@ -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
+110
View File
@@ -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))
+116
View File
@@ -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())
+94
View File
@@ -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())
+86
View File
@@ -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",
}