mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-17 23:40:24 +00:00
initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
config/accounts.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
*.log
|
||||
.vscode/
|
||||
+44
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user