mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-18 07:50:25 +00:00
Compare commits
10
Commits
debaf7dd62
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a7d19948 | ||
|
|
0594a86894 | ||
|
|
43ce0bcfe1 | ||
|
|
113527a4a0 | ||
|
|
5f39d276d6 | ||
|
|
7e2b29a950 | ||
|
|
f307f103cf | ||
|
|
3aa3d648b2 | ||
|
|
4c743036d5 | ||
|
|
619ea84c5f |
@@ -29,3 +29,8 @@ API_PORT=8000
|
|||||||
|
|
||||||
# --- Web-URL (optional, wird in Geburtstags-Mails verlinkt) ---
|
# --- Web-URL (optional, wird in Geburtstags-Mails verlinkt) ---
|
||||||
WEB_URL=https://kontakte.example.de
|
WEB_URL=https://kontakte.example.de
|
||||||
|
|
||||||
|
# --- Chat-Archive API (optional, zeigt Chat-Nachrichten in der Kontakt-Detailseite) ---
|
||||||
|
CHATAPI_ENABLED=false
|
||||||
|
CHATAPI_URL=http://chat-archive-host:8420
|
||||||
|
CHATAPI_KEY=change-me
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ __pycache__/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
title: "iCloud Contacts Sync"
|
||||||
|
emoji: "🔄"
|
||||||
|
category: code
|
||||||
|
subcategory: "Smart Home Apps"
|
||||||
|
status: active
|
||||||
|
stack: [Python, FastAPI, Uvicorn, Jinja2]
|
||||||
@@ -122,6 +122,7 @@ See `.env.example` for full list. Key variables:
|
|||||||
- `AUTH_REMOTE_USER_HEADER` — Authelia header name (default: `Remote-User`)
|
- `AUTH_REMOTE_USER_HEADER` — Authelia header name (default: `Remote-User`)
|
||||||
- `MAILER_ENABLED` — Feature flag for birthday mailer
|
- `MAILER_ENABLED` — Feature flag for birthday mailer
|
||||||
- `MAIL_SEND_HOUR` — Hour (0-23) for daily birthday email
|
- `MAIL_SEND_HOUR` — Hour (0-23) for daily birthday email
|
||||||
|
- `CHATAPI_ENABLED` / `CHATAPI_URL` / `CHATAPI_KEY` — Chat-Archive integration (optional)
|
||||||
|
|
||||||
## Architecture Notes
|
## Architecture Notes
|
||||||
|
|
||||||
@@ -144,6 +145,19 @@ See `.env.example` for full list. Key variables:
|
|||||||
4. Update endpoint table in `SPEC.md` and `README.md`
|
4. Update endpoint table in `SPEC.md` and `README.md`
|
||||||
5. Test with: `curl -H "Remote-User: <user>" http://127.0.0.1:8000/<path>`
|
5. Test with: `curl -H "Remote-User: <user>" http://127.0.0.1:8000/<path>`
|
||||||
|
|
||||||
|
### Querying contact names from the DB
|
||||||
|
**IMPORTANT:** The `full_name` column in `contacts` can be NULL — it is
|
||||||
|
NOT guaranteed to be set. Always SELECT `prefix, given_name, middle_name,
|
||||||
|
family_name, suffix` alongside `full_name` and apply the fallback:
|
||||||
|
```python
|
||||||
|
if not row.get("full_name"):
|
||||||
|
row["full_name"] = db._build_full_name(row)
|
||||||
|
```
|
||||||
|
This is what `_row_to_contact_out()` does for API responses. Any code
|
||||||
|
that needs a contact's display name (e.g. proxying to external APIs)
|
||||||
|
must follow the same pattern. See `get_contact_messages()` in
|
||||||
|
`src/api/main.py` for a reference implementation.
|
||||||
|
|
||||||
### Adding a new contact field
|
### Adding a new contact field
|
||||||
1. Add column to `contacts` table in `sql/schema.sql`
|
1. Add column to `contacts` table in `sql/schema.sql`
|
||||||
2. Update `src/vcard_parser.py` to extract the field
|
2. Update `src/vcard_parser.py` to extract the field
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# icloud-contacts-sync
|
# 🔄 icloud-contacts-sync
|
||||||
|
|
||||||
Synct alle Kontakte mehrerer iCloud-Accounts per CardDAV Delta-Sync
|
Synct alle Kontakte mehrerer iCloud-Accounts per CardDAV Delta-Sync
|
||||||
(RFC 6578) automatisiert alle 15 Minuten in eine MariaDB-Datenbank
|
(RFC 6578) automatisiert alle 15 Minuten in eine MariaDB-Datenbank
|
||||||
@@ -10,17 +10,17 @@ Geburtstage. Für den vollständigen technischen Hintergrund siehe
|
|||||||
[](docs/screenshot2.png)
|
[](docs/screenshot2.png)
|
||||||
|
|
||||||
|
|
||||||
## Voraussetzungen
|
## 📋 Voraussetzungen
|
||||||
|
|
||||||
- Eine oder mehrere Apple-IDs mit aktivierter Zwei-Faktor-Authentifizierung.
|
- 🔐 Eine oder mehrere Apple-IDs mit aktivierter Zwei-Faktor-Authentifizierung.
|
||||||
- Für jede Apple-ID ein app-spezifisches Passwort.
|
- 🔑 Für jede Apple-ID ein app-spezifisches Passwort.
|
||||||
- Eine erreichbare MariaDB-Instanz mit vorbereiteter Datenbank.
|
- 🗄️ Eine erreichbare MariaDB-Instanz mit vorbereiteter Datenbank.
|
||||||
- Ein SMTP-Relay (z. B. dein Mailprovider oder ein lokaler Relay) für
|
- 📬 Ein SMTP-Relay (z. B. dein Mailprovider oder ein lokaler Relay) für
|
||||||
den Geburtstags-Mailer.
|
den Geburtstags-Mailer.
|
||||||
- Docker bzw. Docker Compose auf dem Zielhost (z. B. der Docker-Host auf
|
- 🐳 Docker bzw. Docker Compose auf dem Zielhost (z. B. der Docker-Host auf
|
||||||
deinem Proxmox-Host).
|
deinem Proxmox-Host).
|
||||||
|
|
||||||
## 1. App-spezifische Passwörter erzeugen
|
## 🔑 1. App-spezifische Passwörter erzeugen
|
||||||
|
|
||||||
Für jede Apple-ID, die du syncen willst:
|
Für jede Apple-ID, die du syncen willst:
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ Für jede Apple-ID, die du syncen willst:
|
|||||||
3. Ein neues Passwort mit sprechendem Namen erzeugen (z. B.
|
3. Ein neues Passwort mit sprechendem Namen erzeugen (z. B.
|
||||||
`contacts-sync-debian`) und sofort sichern.
|
`contacts-sync-debian`) und sofort sichern.
|
||||||
|
|
||||||
## 2. Multi-User-Konfiguration anlegen
|
## ⚙️ 2. Multi-User-Konfiguration anlegen
|
||||||
|
|
||||||
```
|
```
|
||||||
cp config/accounts.json.example config/accounts.json
|
cp config/accounts.json.example config/accounts.json
|
||||||
@@ -40,13 +40,15 @@ Trage für jede Apple-ID einen Eintrag mit eindeutigem `name`,
|
|||||||
`apple_email`, `apple_app_password`, `authelia_user` und (optional)
|
`apple_email`, `apple_app_password`, `authelia_user` und (optional)
|
||||||
`birthday_mail_to` ein. Optional kann pro Account eine `healthcheck_url`
|
`birthday_mail_to` ein. Optional kann pro Account eine `healthcheck_url`
|
||||||
konfiguriert werden, die nach jedem erfolgreichen Sync aufgerufen wird
|
konfiguriert werden, die nach jedem erfolgreichen Sync aufgerufen wird
|
||||||
(z.B. für Uptime-Monitoring). Diese Datei
|
(z.B. für Uptime-Monitoring). Optional kann pro Account ein
|
||||||
|
`chat_sender_name` konfiguriert werden, um den Namen in der
|
||||||
|
Chat-Archive DB zuzuordnen (Details siehe Abschnitt 12). Diese Datei
|
||||||
bleibt lokal auf dem Host, sie ist in `.gitignore` ausgeschlossen und
|
bleibt lokal auf dem Host, sie ist in `.gitignore` ausgeschlossen und
|
||||||
wird nur als Volume in den Container gemountet.
|
wird nur als Volume in den Container gemountet.
|
||||||
|
|
||||||
Siehe `config/README.md` für eine vollständige Beschreibung der Felder.
|
Siehe `config/README.md` für eine vollständige Beschreibung der Felder.
|
||||||
|
|
||||||
## 3. Datenbank vorbereiten
|
## 🗄️ 3. Datenbank vorbereiten
|
||||||
|
|
||||||
Falls Datenbank und Benutzer noch nicht existieren, führe dieses Skript einmalig aus:
|
Falls Datenbank und Benutzer noch nicht existieren, führe dieses Skript einmalig aus:
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ Falls Datenbank und Benutzer noch nicht existieren, führe dieses Skript einmali
|
|||||||
mysql -u root -p < sql/db-and-user.sql
|
mysql -u root -p < sql/db-and-user.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Umgebungsvariablen konfigurieren
|
## 🔧 4. Umgebungsvariablen konfigurieren
|
||||||
|
|
||||||
```
|
```
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -66,7 +68,7 @@ Mailer nutzen willst) `SMTP_HOST` und `MAIL_FROM` ein. Die
|
|||||||
Empfänger-Adresse wird pro Account in `accounts.json` unter
|
Empfänger-Adresse wird pro Account in `accounts.json` unter
|
||||||
`birthday_mail_to` konfiguriert.
|
`birthday_mail_to` konfiguriert.
|
||||||
|
|
||||||
## 5. Image beziehen
|
## 🐳 5. Image beziehen
|
||||||
|
|
||||||
```
|
```
|
||||||
docker login ghcr.io -u DEIN_GITHUB_USER
|
docker login ghcr.io -u DEIN_GITHUB_USER
|
||||||
@@ -76,7 +78,7 @@ Passe in `docker-compose.yml` den Image-Namen
|
|||||||
(`ghcr.io/DEIN_GITHUB_USER/icloud-contacts-sync:latest`) auf deinen
|
(`ghcr.io/DEIN_GITHUB_USER/icloud-contacts-sync:latest`) auf deinen
|
||||||
tatsächlichen GitHub-Namespace an.
|
tatsächlichen GitHub-Namespace an.
|
||||||
|
|
||||||
## 6. Starten
|
## 🚀 6. Starten
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -87,7 +89,7 @@ initialer Sync ausgeführt (kein gespeicherter sync-token vorhanden).
|
|||||||
Danach laufen alle 15 Minuten nur noch Delta-Syncs, die ausschließlich
|
Danach laufen alle 15 Minuten nur noch Delta-Syncs, die ausschließlich
|
||||||
Änderungen seit dem letzten Lauf übertragen.
|
Änderungen seit dem letzten Lauf übertragen.
|
||||||
|
|
||||||
## 7. Logs und Status prüfen
|
## 📊 7. Logs und Status prüfen
|
||||||
|
|
||||||
```
|
```
|
||||||
docker logs -f icloud-contacts-sync
|
docker logs -f icloud-contacts-sync
|
||||||
@@ -116,7 +118,7 @@ SELECT account, sent_date, contacts_count, sent_at FROM birthday_mail_log
|
|||||||
ORDER BY sent_date DESC LIMIT 10;
|
ORDER BY sent_date DESC LIMIT 10;
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. Geburtstags-Mailer
|
## 🎂 8. Geburtstags-Mailer
|
||||||
|
|
||||||
- Läuft automatisch täglich um die in `MAIL_SEND_HOUR` konfigurierte
|
- Läuft automatisch täglich um die in `MAIL_SEND_HOUR` konfigurierte
|
||||||
Stunde (Default 7 Uhr) innerhalb desselben Containers.
|
Stunde (Default 7 Uhr) innerhalb desselben Containers.
|
||||||
@@ -133,7 +135,7 @@ ORDER BY sent_date DESC LIMIT 10;
|
|||||||
pro Account, solange bereits ein Eintrag in `birthday_mail_log` für
|
pro Account, solange bereits ein Eintrag in `birthday_mail_log` für
|
||||||
heute und diesen Account existiert.
|
heute und diesen Account existiert.
|
||||||
|
|
||||||
## 9. Lokale Entwicklung (ohne Docker)
|
## 💻 9. Lokale Entwicklung (ohne Docker)
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
@@ -144,7 +146,7 @@ python3 sync.py
|
|||||||
python3 mailer.py
|
python3 mailer.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## 10. Demo-Modus (Screenshot/Showcase)
|
## 🎬 10. Demo-Modus (Screenshot/Showcase)
|
||||||
|
|
||||||
Lokale Demo mit SQLite-Backend und Fake-Kontakten, ohne MariaDB,
|
Lokale Demo mit SQLite-Backend und Fake-Kontakten, ohne MariaDB,
|
||||||
Apple-IDs oder Docker. Zeigt Dashboard, Kontakt-Detailseite und
|
Apple-IDs oder Docker. Zeigt Dashboard, Kontakt-Detailseite und
|
||||||
@@ -153,28 +155,28 @@ Gruppen-Übersicht mit farbigen UI-Avatar-Bildern.
|
|||||||
### Starten
|
### Starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./demo.sh
|
▶️ ./demo.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Script erstellt automatisch ein virtuelles Umfeld
|
Das Script erstellt automatisch ein virtuelles Umfeld
|
||||||
(`.venv-demo/`), installiert die Dependencies und startet den
|
(`.venv-demo/`), installiert die Dependencies und startet den
|
||||||
Server auf `0.0.0.0:8000`.
|
Server auf `0.0.0.0:8000`.
|
||||||
|
|
||||||
### Was angezeigt wird
|
### 👀 Was angezeigt wird
|
||||||
|
|
||||||
- Dashboard mit 6 Kontakten, Geburtstagen der nächsten 7 Tage,
|
- Dashboard mit 6 Kontakten, Geburtstagen der nächsten 7 Tage,
|
||||||
2 Gruppen ("Familie", "Arbeit") und成功stem Sync-Status
|
2 Gruppen ("Familie", "Arbeit") und成功stem Sync-Status
|
||||||
- Kontakt-Detailseite mit E-Mail, Telefon, Adresse, Foto
|
- Kontakt-Detailseite mit E-Mail, Telefon, Adresse, Foto
|
||||||
- Farbige Initialen-Avatare via ui-avatars.com
|
- Farbige Initialen-Avatare via ui-avatars.com
|
||||||
|
|
||||||
### Technisches
|
### 🔧 Technisches
|
||||||
|
|
||||||
- SQLite-Datenbank (`demo.db`) wird bei jedem Start frisch angelegt
|
- SQLite-Datenbank (`demo.db`) wird bei jedem Start frisch angelegt
|
||||||
- Kein `.env`, kein `accounts.json` nötig
|
- Kein `.env`, kein `accounts.json` nötig
|
||||||
- Templates und CSS werden aus `src/api/` wiederverwendet
|
- Templates und CSS werden aus `src/api/` wiederverwendet
|
||||||
- `.venv-demo/` und `demo.db` sind in `.gitignore` eingetragen
|
- `.venv-demo/` und `demo.db` sind in `.gitignore` eingetragen
|
||||||
|
|
||||||
## 11. CI/CD
|
## ⚡ 11. CI/CD
|
||||||
|
|
||||||
- Jeder Push auf `main` baut automatisch ein neues Image und pusht es
|
- Jeder Push auf `main` baut automatisch ein neues Image und pusht es
|
||||||
nach `ghcr.io/<owner>/icloud-contacts-sync`.
|
nach `ghcr.io/<owner>/icloud-contacts-sync`.
|
||||||
@@ -185,14 +187,14 @@ Server auf `0.0.0.0:8000`.
|
|||||||
reproduzierbar und unabhängig von neuen Ruff-Defaults.
|
reproduzierbar und unabhängig von neuen Ruff-Defaults.
|
||||||
- Details siehe SPEC.md, Abschnitt 9.
|
- Details siehe SPEC.md, Abschnitt 9.
|
||||||
|
|
||||||
## Bekannte Grenzen und geplante Erweiterungen
|
## ⚠️ Bekannte Grenzen und geplante Erweiterungen
|
||||||
|
|
||||||
- Delta-Sync reduziert die übertragene Datenmenge stark, ersetzt aber
|
- Delta-Sync reduziert die übertragene Datenmenge stark, ersetzt aber
|
||||||
keine vollständige Historie: ein gelöschter iCloud-Kontakt wird auch
|
keine vollständige Historie: ein gelöschter iCloud-Kontakt wird auch
|
||||||
aus MariaDB entfernt, ohne Archiv.
|
aus MariaDB entfernt, ohne Archiv.
|
||||||
- Nur iCloud als Quelle, Google/Microsoft sind nicht Teil dieses Repos.
|
- Nur iCloud als Quelle, Google/Microsoft sind nicht Teil dieses Repos.
|
||||||
|
|
||||||
## Kontaktruppen
|
## 👥 Kontaktruppen
|
||||||
|
|
||||||
iCloud-Länder speichern Gruppen als eigene vCards mit
|
iCloud-Länder speichern Gruppen als eigene vCards mit
|
||||||
`X-ADDRESSBOOKSERVER-KIND:group`. Diese werden beim Sync automatisch
|
`X-ADDRESSBOOKSERVER-KIND:group`. Diese werden beim Sync automatisch
|
||||||
@@ -211,7 +213,7 @@ entfernt (`ON DELETE CASCADE`). Wird ein Mitglied-Kontakt gelöscht,
|
|||||||
wird der Member-Eintrag in allen Gruppen ebenfalls entfernt (manueller
|
wird der Member-Eintrag in allen Gruppen ebenfalls entfernt (manueller
|
||||||
Cleanup im Sync-Code). Die Gruppe selbst bleibt erhalten.
|
Cleanup im Sync-Code). Die Gruppe selbst bleibt erhalten.
|
||||||
|
|
||||||
### Migration bei erstem Deploy
|
### 🔄 Migration bei erstem Deploy
|
||||||
|
|
||||||
Bei Bestands-DBs lagen Gruppen bisher als normale Kontakte in der
|
Bei Bestands-DBs lagen Gruppen bisher als normale Kontakte in der
|
||||||
`contacts`-Tabelle. Nach dem Deploy müssen diese einmalig bereinigt
|
`contacts`-Tabelle. Nach dem Deploy müssen diese einmalig bereinigt
|
||||||
@@ -224,14 +226,14 @@ werden:
|
|||||||
Beim nächsten Sync-Lauf werden alle vCards neu klassifiziziert —
|
Beim nächsten Sync-Lauf werden alle vCards neu klassifiziziert —
|
||||||
Gruppen landen in `groups`, Kontakte bleiben in `contacts`.
|
Gruppen landen in `groups`, Kontakte bleiben in `contacts`.
|
||||||
|
|
||||||
## 11. Web-Ansicht und API (interner Zugriff über Authelia)
|
## 🌐 11. Web-Ansicht und API (interner Zugriff über Authelia)
|
||||||
|
|
||||||
Läuft als zweiter Service aus demselben Image, aber mit anderem
|
Läuft als zweiter Service aus demselben Image, aber mit anderem
|
||||||
Startbefehl, siehe `docker-compose.yml` (`icloud-contacts-api`). Die API
|
Startbefehl, siehe `docker-compose.yml` (`icloud-contacts-api`). Die API
|
||||||
selbst hat kein eigenes Login, sie vertraut vollständig dem
|
selbst hat kein eigenes Login, sie vertraut vollständig dem
|
||||||
vorgeschalteten Reverse-Proxy mit Authelia.
|
vorgeschalteten Reverse-Proxy mit Authelia.
|
||||||
|
|
||||||
### Voraussetzung: Reverse-Proxy mit Authelia
|
### 🔐 Voraussetzung: Reverse-Proxy mit Authelia
|
||||||
|
|
||||||
Dein bestehender Reverse-Proxy muss für den Pfad/Host der
|
Dein bestehender Reverse-Proxy muss für den Pfad/Host der
|
||||||
Web-Ansicht einen `auth_request` gegen Authelia ausführen und danach
|
Web-Ansicht einen `auth_request` gegen Authelia ausführen und danach
|
||||||
@@ -250,7 +252,7 @@ location / {
|
|||||||
Falls dein Setup den Benutzernamen unter einem anderen Header liefert,
|
Falls dein Setup den Benutzernamen unter einem anderen Header liefert,
|
||||||
passe `AUTH_REMOTE_USER_HEADER` in der `.env` entsprechend an.
|
passe `AUTH_REMOTE_USER_HEADER` in der `.env` entsprechend an.
|
||||||
|
|
||||||
### Accounts-Mapping ergänzen
|
### 👤 Accounts-Mapping ergänzen
|
||||||
|
|
||||||
In `config/accounts.json` bekommt jeder Account zusätzlich ein Feld
|
In `config/accounts.json` bekommt jeder Account zusätzlich ein Feld
|
||||||
`authelia_user`:
|
`authelia_user`:
|
||||||
@@ -267,7 +269,7 @@ In `config/accounts.json` bekommt jeder Account zusätzlich ein Feld
|
|||||||
Ein Benutzer aus `admins` sieht alle Accounts, alle anderen gemappten
|
Ein Benutzer aus `admins` sieht alle Accounts, alle anderen gemappten
|
||||||
Benutzer sehen ausschließlich ihren eigenen Account.
|
Benutzer sehen ausschließlich ihren eigenen Account.
|
||||||
|
|
||||||
### Starten
|
### 🚀 Starten
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose up -d icloud-contacts-api
|
docker compose up -d icloud-contacts-api
|
||||||
@@ -276,7 +278,7 @@ docker compose up -d icloud-contacts-api
|
|||||||
Der Service läuft nur an `127.0.0.1:8000`, ein direkter externer
|
Der Service läuft nur an `127.0.0.1:8000`, ein direkter externer
|
||||||
Zugriff ohne den Reverse-Proxy ist damit nicht möglich.
|
Zugriff ohne den Reverse-Proxy ist damit nicht möglich.
|
||||||
|
|
||||||
### Endpunkte (Routes)
|
### 📍 Endpunkte (Routes)
|
||||||
|
|
||||||
| Methode | Pfad | Beschreibung |
|
| Methode | Pfad | Beschreibung |
|
||||||
|---------|------|--------------|
|
|---------|------|--------------|
|
||||||
@@ -294,16 +296,56 @@ Zugriff ohne den Reverse-Proxy ist damit nicht möglich.
|
|||||||
| `GET` | `/api/groups/{id}/members` | Members einer Gruppe (Kontaktdaten) |
|
| `GET` | `/api/groups/{id}/members` | Members einer Gruppe (Kontaktdaten) |
|
||||||
| `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) |
|
| `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) |
|
||||||
|
|
||||||
|
**Chat-Archive (optional):**
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| `GET` | `/api/contacts/{id}/messages` | Chat-Nachrichten eines Kontakts via Chat-Archive API (`?offset=0&limit=50`) |
|
||||||
|
|
||||||
Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung
|
Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung
|
||||||
über den `Remote-User`-Header. Nicht-Admins sehen nur die Daten ihres
|
über den `Remote-User`-Header. Nicht-Admins sehen nur die Daten ihres
|
||||||
eigenen Accounts.
|
eigenen Accounts.
|
||||||
|
|
||||||
### API kurz testen (lokal auf der Docker-Host, mit Header simuliert)
|
### 🧪 API kurz testen (lokal auf der Docker-Host, mit Header simuliert)
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -H "Remote-User: mmustermann" http://127.0.0.1:8000/api/contacts
|
curl -H "Remote-User: mmustermann" http://127.0.0.1:8000/api/contacts
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## 💬 12. Chat-Archive Integration (optional)
|
||||||
|
|
||||||
|
Zeigt Chat-Nachrichten (Instagram/Facebook) direkt in der
|
||||||
|
Kontakt-Detailseite, mit Infinite Scroll und Messenger-Style Bubbles.
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
|
||||||
|
- Eine laufende [Chat-Archive API](https://github.com/stefan-koelle/chat-archive)
|
||||||
|
mit importierten Nachrichten.
|
||||||
|
|
||||||
|
### Konfiguration
|
||||||
|
|
||||||
|
In `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
CHATAPI_ENABLED=true
|
||||||
|
CHATAPI_URL=http://chat-archive-host:8420
|
||||||
|
CHATAPI_KEY=change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
In `config/accounts.json` pro Account das `chat_sender_name` setzen
|
||||||
|
(Name wie in der Chat-Archive DB als `sender_name` gespeichert):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "iCloud Stefan",
|
||||||
|
"authelia_user": "stefan",
|
||||||
|
"chat_sender_name": "Stefan Koelle"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Name wird umlaut-normalisiert verglichen: "Koelle" und "Kölle"
|
||||||
|
werden als identisch erkannt.
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ außerhalb des Apple-Ökosystems.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{ "name": "markus", "apple_email": "markus@icloud.com", "apple_app_password": "xxxx-xxxx-xxxx-xxxx", "authelia_user": "mmustermann", "birthday_mail_to": "markus@example.de", "healthcheck_url": "https://healthchecks.example.de/ping/abc123" },
|
{ "name": "markus", "apple_email": "markus@icloud.com", "apple_app_password": "xxxx-xxxx-xxxx-xxxx", "authelia_user": "mmustermann", "birthday_mail_to": "markus@example.de", "healthcheck_url": "https://healthchecks.example.de/ping/abc123", "chat_sender_name": "Markus Mustermann" },
|
||||||
{ "name": "partner", "apple_email": "partner@icloud.com", "apple_app_password": "yyyy-yyyy-yyyy-yyyy", "authelia_user": "pmustermann", "birthday_mail_to": "partner@example.de" }
|
{ "name": "partner", "apple_email": "partner@icloud.com", "apple_app_password": "yyyy-yyyy-yyyy-yyyy", "authelia_user": "pmustermann", "birthday_mail_to": "partner@example.de" }
|
||||||
],
|
],
|
||||||
"admins": ["mmustermann"]
|
"admins": ["mmustermann"]
|
||||||
@@ -152,6 +152,9 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1:
|
|||||||
| AUTH_REMOTE_USER_HEADER | nein | Default: Remote-User, Header-Name für Authelia-User |
|
| AUTH_REMOTE_USER_HEADER | nein | Default: Remote-User, Header-Name für Authelia-User |
|
||||||
| API_HOST | nein | Default: 0.0.0.0, Bindungs-Adresse des API-Services |
|
| API_HOST | nein | Default: 0.0.0.0, Bindungs-Adresse des API-Services |
|
||||||
| API_PORT | nein | Default: 8000, Port des API-Services |
|
| API_PORT | nein | Default: 8000, Port des API-Services |
|
||||||
|
| CHATAPI_ENABLED | nein | Default: false, aktiviert Chat-Archive Integration |
|
||||||
|
| CHATAPI_URL | nein | Basis-URL der Chat-Archive API |
|
||||||
|
| CHATAPI_KEY | nein | API-Key für Chat-Archive Authentifizierung |
|
||||||
|
|
||||||
Empfänger-Adresse für Geburtstags-Mails: `birthday_mail_to` pro Account
|
Empfänger-Adresse für Geburtstags-Mails: `birthday_mail_to` pro Account
|
||||||
in `accounts.json` (keine globale Umgebungsvariable mehr nötig).
|
in `accounts.json` (keine globale Umgebungsvariable mehr nötig).
|
||||||
@@ -279,6 +282,7 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft.
|
|||||||
| `GET /api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members (Name + UID) |
|
| `GET /api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members (Name + UID) |
|
||||||
| `GET /api/groups/{id}/members` | Nur Members einer Gruppe (Kontaktdaten aufgelöst) |
|
| `GET /api/groups/{id}/members` | Nur Members einer Gruppe (Kontaktdaten aufgelöst) |
|
||||||
| `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) |
|
| `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) |
|
||||||
|
| `GET /api/contacts/{id}/messages` | Chat-Nachrichten via Chat-Archive API (optional, `?offset=0&limit=50`) |
|
||||||
|
|
||||||
### 12.5 Netzwerkkontext
|
### 12.5 Netzwerkkontext
|
||||||
|
|
||||||
@@ -287,3 +291,27 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft.
|
|||||||
- Externer Zugriff läuft über deinen bestehenden Reverse-Proxy mit
|
- Externer Zugriff läuft über deinen bestehenden Reverse-Proxy mit
|
||||||
Authelia im internen Netzwerk (`deinem lokalen Netz`), der intern auf
|
Authelia im internen Netzwerk (`deinem lokalen Netz`), der intern auf
|
||||||
`127.0.0.1:8000` weiterleitet und den `Remote-User`-Header setzt.
|
`127.0.0.1:8000` weiterleitet und den `Remote-User`-Header setzt.
|
||||||
|
|
||||||
|
### 12.6 Chat-Archive Integration (optional)
|
||||||
|
|
||||||
|
- Feature-Flag `CHATAPI_ENABLED` (Default: `false`).
|
||||||
|
- Bei aktivierter Integration zeigt die Kontakt-Detailseite
|
||||||
|
(`/contacts/{id}`) Chat-Nachrichten des Kontakts aus einer externen
|
||||||
|
[Chat-Archive API](https://github.com/stefan-koelle/chat-archive).
|
||||||
|
- Der API-Container agiert als Proxy: der Browser ruft
|
||||||
|
`GET /api/contacts/{id}/messages` auf, der Server liest den
|
||||||
|
`full_name` des Kontakts aus der DB und leitet die Anfrage an
|
||||||
|
`CHATAPI_URL/conversation?contact_names={full_name}&order=desc` weiter.
|
||||||
|
- Der API-Key wird serverseitig aus `CHATAPI_KEY` gelesen, der Browser
|
||||||
|
erhält nie Zugriff auf das Geheimnis.
|
||||||
|
- **Namens-Matching**: Das optionale Feld `chat_sender_name` pro Account
|
||||||
|
in `accounts.json` gibt den Namen an, der als eigene Nachricht
|
||||||
|
erkannt wird (z.B. "Stefan Koelle"). Der Vergleich erfolgt
|
||||||
|
umlaut-normalisiert: "Koelle" und "Kölle" werden als identisch
|
||||||
|
erkannt.
|
||||||
|
- **Infinite Scroll**: Das Frontend lädt initial 50 Nachrichten
|
||||||
|
(neueste zuerst) und lädt bei Bedarf weitere Batches nach, indem
|
||||||
|
ein Intersection Observer den `offset`-Parameter erhöht.
|
||||||
|
- **Darstellung**: Chat-Bubbles im Messenger-Style, eigene Nachrichten
|
||||||
|
rechts (blau), Kontaktnachrichten links (grau). Plattform-Badge
|
||||||
|
(Instagram/Facebook) und Zeitstempel werden angezeigt.
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ services:
|
|||||||
MAIL_FROM: "${MAIL_FROM}"
|
MAIL_FROM: "${MAIL_FROM}"
|
||||||
TIMEZONE: "${TIMEZONE:-Europe/Berlin}"
|
TIMEZONE: "${TIMEZONE:-Europe/Berlin}"
|
||||||
WEB_URL: "${WEB_URL:-}"
|
WEB_URL: "${WEB_URL:-}"
|
||||||
|
CHATAPI_ENABLED: "${CHATAPI_ENABLED:-false}"
|
||||||
|
CHATAPI_URL: "${CHATAPI_URL:-}"
|
||||||
|
CHATAPI_KEY: "${CHATAPI_KEY:-}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/accounts.json:/app/config/accounts.json:ro
|
- ./config/accounts.json:/app/config/accounts.json:ro
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+61
-1
@@ -13,8 +13,9 @@ import secrets
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
import requests
|
||||||
from fastapi import Depends, FastAPI, Query, Request
|
from fastapi import Depends, FastAPI, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
@@ -211,6 +212,58 @@ def contact_count(current_user: str = Depends(get_current_user)):
|
|||||||
return {"total": total}
|
return {"total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/contacts/{contact_id}/messages")
|
||||||
|
def get_contact_messages(
|
||||||
|
contact_id: int,
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not Config.CHATAPI_ENABLED:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Chat-Archive nicht aktiviert"})
|
||||||
|
|
||||||
|
with db.get_connection() as conn:
|
||||||
|
where_clause, params = _account_filter_clause(
|
||||||
|
resolve_account_for_user(current_user)[0]
|
||||||
|
)
|
||||||
|
id_clause = "AND id = %s" if where_clause else "WHERE id = %s"
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""SELECT full_name, prefix, given_name, middle_name, family_name, suffix
|
||||||
|
FROM contacts {where_clause} {id_clause}""",
|
||||||
|
params + [contact_id],
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Kontakt nicht gefunden"})
|
||||||
|
|
||||||
|
if not row.get("full_name"):
|
||||||
|
row["full_name"] = db._build_full_name(row)
|
||||||
|
|
||||||
|
if not row.get("full_name"):
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Kontakt hat keinen Namen"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{Config.CHATAPI_URL.rstrip('/')}/conversation",
|
||||||
|
params={
|
||||||
|
"contact_names": [row["full_name"]],
|
||||||
|
"order": "desc",
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
headers={"X-API-Key": Config.CHATAPI_KEY},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.warning("Chat-Archive API Fehler: %s", e)
|
||||||
|
return JSONResponse(status_code=502, content={"detail": "Chat-Archive nicht erreichbar"})
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/sync-runs", response_model=list[SyncRunOut])
|
@app.get("/api/sync-runs", response_model=list[SyncRunOut])
|
||||||
def list_sync_runs(current_user: str = Depends(get_current_user)):
|
def list_sync_runs(current_user: str = Depends(get_current_user)):
|
||||||
account_name, is_admin = resolve_account_for_user(current_user)
|
account_name, is_admin = resolve_account_for_user(current_user)
|
||||||
@@ -536,6 +589,7 @@ def web_search_special(
|
|||||||
"no_photo": db.search_contacts_without_photo,
|
"no_photo": db.search_contacts_without_photo,
|
||||||
"no_city": db.search_contacts_without_city,
|
"no_city": db.search_contacts_without_city,
|
||||||
"no_social": db.search_contacts_without_social,
|
"no_social": db.search_contacts_without_social,
|
||||||
|
"last_updated": db.search_contacts_last_updated,
|
||||||
}.get(type)
|
}.get(type)
|
||||||
|
|
||||||
if not query_fn:
|
if not query_fn:
|
||||||
@@ -545,6 +599,7 @@ def web_search_special(
|
|||||||
"no_photo": "Kontakte ohne Bild",
|
"no_photo": "Kontakte ohne Bild",
|
||||||
"no_city": "Kontakte ohne Stadt",
|
"no_city": "Kontakte ohne Stadt",
|
||||||
"no_social": "Kontakte ohne Social Profil",
|
"no_social": "Kontakte ohne Social Profil",
|
||||||
|
"last_updated": "Zuletzt aktualisiert",
|
||||||
}
|
}
|
||||||
|
|
||||||
with db.get_connection() as conn:
|
with db.get_connection() as conn:
|
||||||
@@ -675,12 +730,14 @@ def web_contact(
|
|||||||
workcity = city
|
workcity = city
|
||||||
|
|
||||||
custom_links = []
|
custom_links = []
|
||||||
|
chat_sender_name = ""
|
||||||
contact_account = contact.get("account")
|
contact_account = contact.get("account")
|
||||||
if contact_account:
|
if contact_account:
|
||||||
accounts = Config.load_accounts()
|
accounts = Config.load_accounts()
|
||||||
for acc in accounts:
|
for acc in accounts:
|
||||||
if acc.name == contact_account:
|
if acc.name == contact_account:
|
||||||
custom_links = acc.custom_links
|
custom_links = acc.custom_links
|
||||||
|
chat_sender_name = acc.chat_sender_name
|
||||||
break
|
break
|
||||||
|
|
||||||
resolved_links = []
|
resolved_links = []
|
||||||
@@ -706,5 +763,8 @@ def web_contact(
|
|||||||
"groups": groups,
|
"groups": groups,
|
||||||
"search": search or "",
|
"search": search or "",
|
||||||
"custom_links": resolved_links,
|
"custom_links": resolved_links,
|
||||||
|
"chat_enabled": Config.CHATAPI_ENABLED,
|
||||||
|
"chat_sender_name": chat_sender_name,
|
||||||
|
"contact_id": contact_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,5 +92,6 @@
|
|||||||
<a href="/search/special?type=no_photo" class="special-search-link">ohne Bild</a>
|
<a href="/search/special?type=no_photo" class="special-search-link">ohne Bild</a>
|
||||||
<a href="/search/special?type=no_city" class="special-search-link">ohne City</a>
|
<a href="/search/special?type=no_city" class="special-search-link">ohne City</a>
|
||||||
<a href="/search/special?type=no_social" class="special-search-link">ohne Social</a>
|
<a href="/search/special?type=no_social" class="special-search-link">ohne Social</a>
|
||||||
|
<a href="/search/special?type=last_updated" class="special-search-link">last updated</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -320,6 +320,146 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.own {
|
||||||
|
align-self: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.other {
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-sender {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-bubble {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-bubble a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.own .chat-msg-bubble a {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.own .chat-msg-bubble {
|
||||||
|
background: #0066cc;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.other .chat-msg-bubble {
|
||||||
|
background: #e9ecef;
|
||||||
|
color: #333;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-time {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-platform {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-type {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-end {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reaction {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reaction-emoji {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -480,6 +620,17 @@
|
|||||||
<div class="notes-content">{{ contact.notes }}</div>
|
<div class="notes-content">{{ contact.notes }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if chat_enabled %}
|
||||||
|
<div class="chat-section">
|
||||||
|
<div class="section-title">Nachrichten</div>
|
||||||
|
<div id="chat-messages" class="chat-messages">
|
||||||
|
<div id="chat-loading" class="chat-loading" style="display:none">Lade Nachrichten…</div>
|
||||||
|
<div id="chat-end" class="chat-end" style="display:none"></div>
|
||||||
|
<div id="chat-sentinel" style="height:1px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if custom_links %}
|
{% if custom_links %}
|
||||||
@@ -501,5 +652,127 @@
|
|||||||
<img src="{{ contact.photo_url }}" alt="Foto" onerror="this.parentElement.close()">
|
<img src="{{ contact.photo_url }}" alt="Foto" onerror="this.parentElement.close()">
|
||||||
</dialog>
|
</dialog>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if chat_enabled %}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const contactId = {{ contact_id }};
|
||||||
|
const ownName = {{ chat_sender_name|tojson }};
|
||||||
|
const chatBox = document.getElementById("chat-messages");
|
||||||
|
const loading = document.getElementById("chat-loading");
|
||||||
|
const endEl = document.getElementById("chat-end");
|
||||||
|
const sentinel = document.getElementById("chat-sentinel");
|
||||||
|
let offset = 0;
|
||||||
|
const batchSize = 50;
|
||||||
|
let total = null;
|
||||||
|
let loading_ = false;
|
||||||
|
|
||||||
|
function fmtTime(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const pad = n => String(n).padStart(2, "0");
|
||||||
|
return pad(d.getDate()) + "." + pad(d.getMonth()+1) + "." + d.getFullYear() +
|
||||||
|
" " + pad(d.getHours()) + ":" + pad(d.getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm(s) {
|
||||||
|
return s.toLowerCase()
|
||||||
|
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue")
|
||||||
|
.replace(/ß/g, "ss").replace(/é/g, "e").replace(/è/g, "e")
|
||||||
|
.replace(/ê/g, "e").replace(/ë/g, "e").replace(/á/g, "a")
|
||||||
|
.replace(/à/g, "a").replace(/â/g, "a").replace(/í/g, "i")
|
||||||
|
.replace(/ì/g, "i").replace(/î/g, "i").replace(/ó/g, "o")
|
||||||
|
.replace(/ò/g, "o").replace(/ô/g, "o").replace(/ú/g, "u")
|
||||||
|
.replace(/ù/g, "u").replace(/û/g, "u");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownNorm = norm(ownName);
|
||||||
|
|
||||||
|
function linkify(text) {
|
||||||
|
return text.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMsg(m) {
|
||||||
|
const isOwn = norm(m.sender_name) === ownNorm;
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "chat-msg " + (isOwn ? "own" : "other");
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
if (!isOwn && total !== null && total > batchSize) {
|
||||||
|
html += '<div class="chat-msg-sender">' + escHtml(m.sender_name) + '</div>';
|
||||||
|
}
|
||||||
|
const text = m.content ? linkify(escHtml(m.content)) : '<span class="chat-msg-type">[' + m.message_type + ']</span>';
|
||||||
|
html += '<div class="chat-msg-bubble">' + text + '</div>';
|
||||||
|
if (m.reactions && m.reactions.length > 0) {
|
||||||
|
html += '<div class="chat-reactions">';
|
||||||
|
for (const r of m.reactions) {
|
||||||
|
html += '<span class="chat-reaction"><span class="chat-reaction-emoji">' + escHtml(r.reaction || r.emoji || "") + '</span>' + (r.user ? " " + escHtml(r.user) : "") + '</span>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '<div class="chat-msg-meta">';
|
||||||
|
html += '<span class="chat-msg-time">' + fmtTime(m.timestamp_ms) + '</span>';
|
||||||
|
if (m.platform) {
|
||||||
|
html += '<span class="chat-msg-platform">' + escHtml(m.platform) + '</span>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
div.innerHTML = html;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
const el = document.createElement("span");
|
||||||
|
el.textContent = s;
|
||||||
|
return el.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loading_) return;
|
||||||
|
loading_ = true;
|
||||||
|
loading.style.display = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/contacts/" + contactId + "/messages?offset=" + offset + "&limit=" + batchSize);
|
||||||
|
if (!resp.ok) {
|
||||||
|
loading.style.display = "none";
|
||||||
|
loading_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
if (total === null) total = data.total;
|
||||||
|
loading.style.display = "none";
|
||||||
|
if (!data.messages || data.messages.length === 0) {
|
||||||
|
if (total === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "chat-empty";
|
||||||
|
empty.textContent = "Keine Nachrichten gefunden";
|
||||||
|
chatBox.insertBefore(empty, sentinel);
|
||||||
|
} else {
|
||||||
|
endEl.textContent = "Alle Nachrichten geladen";
|
||||||
|
endEl.style.display = "";
|
||||||
|
}
|
||||||
|
sentinel.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const m of data.messages) {
|
||||||
|
chatBox.insertBefore(renderMsg(m), sentinel);
|
||||||
|
}
|
||||||
|
offset += data.messages.length;
|
||||||
|
if (offset >= total) {
|
||||||
|
endEl.textContent = "Alle " + total + " Nachrichten geladen";
|
||||||
|
endEl.style.display = "";
|
||||||
|
sentinel.style.display = "none";
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
loading.style.display = "none";
|
||||||
|
}
|
||||||
|
loading_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0].isIntersecting && !loading_) loadMore();
|
||||||
|
}, { root: chatBox, rootMargin: "200px" });
|
||||||
|
observer.observe(sentinel);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+8
-2
@@ -14,7 +14,7 @@ ICLOUD_BASE_URL = "https://contacts.icloud.com/"
|
|||||||
class Account:
|
class Account:
|
||||||
def __init__(self, name: str, apple_email: str, apple_app_password: str, authelia_user: str | None,
|
def __init__(self, name: str, apple_email: str, apple_app_password: str, authelia_user: str | None,
|
||||||
custom_links: list[dict] | None = None, healthcheck_url: str = "",
|
custom_links: list[dict] | None = None, healthcheck_url: str = "",
|
||||||
birthday_mail_to: str | None = None):
|
birthday_mail_to: str | None = None, chat_sender_name: str = ""):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.apple_email = apple_email
|
self.apple_email = apple_email
|
||||||
self.apple_app_password = apple_app_password
|
self.apple_app_password = apple_app_password
|
||||||
@@ -22,6 +22,7 @@ class Account:
|
|||||||
self.custom_links = custom_links or []
|
self.custom_links = custom_links or []
|
||||||
self.healthcheck_url = healthcheck_url
|
self.healthcheck_url = healthcheck_url
|
||||||
self.birthday_mail_to = birthday_mail_to
|
self.birthday_mail_to = birthday_mail_to
|
||||||
|
self.chat_sender_name = chat_sender_name
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -50,6 +51,10 @@ class Config:
|
|||||||
# vorgeschalteten nginx/traefik als Remote-User weitergereicht wird.
|
# vorgeschalteten nginx/traefik als Remote-User weitergereicht wird.
|
||||||
AUTH_REMOTE_USER_HEADER = os.environ.get("AUTH_REMOTE_USER_HEADER", "Remote-User")
|
AUTH_REMOTE_USER_HEADER = os.environ.get("AUTH_REMOTE_USER_HEADER", "Remote-User")
|
||||||
|
|
||||||
|
CHATAPI_ENABLED = os.environ.get("CHATAPI_ENABLED", "false").lower() == "true"
|
||||||
|
CHATAPI_URL = os.environ.get("CHATAPI_URL", "")
|
||||||
|
CHATAPI_KEY = os.environ.get("CHATAPI_KEY", "")
|
||||||
|
|
||||||
API_HOST = os.environ.get("API_HOST", "0.0.0.0")
|
API_HOST = os.environ.get("API_HOST", "0.0.0.0")
|
||||||
API_PORT = int(os.environ.get("API_PORT", "8000"))
|
API_PORT = int(os.environ.get("API_PORT", "8000"))
|
||||||
WEB_URL = os.environ.get("WEB_URL", "")
|
WEB_URL = os.environ.get("WEB_URL", "")
|
||||||
@@ -104,7 +109,8 @@ class Config:
|
|||||||
custom_links = entry.get("custom_links", [])
|
custom_links = entry.get("custom_links", [])
|
||||||
healthcheck_url = entry.get("healthcheck_url", "")
|
healthcheck_url = entry.get("healthcheck_url", "")
|
||||||
birthday_mail_to = entry.get("birthday_mail_to") or None
|
birthday_mail_to = entry.get("birthday_mail_to") or None
|
||||||
accounts.append(Account(name, email, pwd, authelia_user, custom_links, healthcheck_url, birthday_mail_to))
|
chat_sender_name = entry.get("chat_sender_name", "")
|
||||||
|
accounts.append(Account(name, email, pwd, authelia_user, custom_links, healthcheck_url, birthday_mail_to, chat_sender_name))
|
||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -232,6 +232,24 @@ def search_contacts_without_social(conn, account: str | None) -> list[dict]:
|
|||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def search_contacts_last_updated(conn, account: str | None) -> list[dict]:
|
||||||
|
where_clause, params = _account_filter_clause(account)
|
||||||
|
op = "AND" if where_clause else "WHERE"
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""SELECT id, full_name, given_name, middle_name, family_name,
|
||||||
|
prefix, suffix, organization, birthday, account, photo_url
|
||||||
|
FROM contacts {where_clause}
|
||||||
|
{op} given_name IS NOT NULL AND given_name != ''
|
||||||
|
AND family_name IS NOT NULL AND family_name != ''
|
||||||
|
AND family_name != 'X'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 200""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
def get_most_common_birthday(conn) -> date | None:
|
def get_most_common_birthday(conn) -> date | None:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|||||||
+5
-2
@@ -69,7 +69,10 @@ def build_message(account_name: str, birthdays: list[dict], target_date: date |
|
|||||||
msg.set_content("Heute hat niemand aus deinen Kontakten Geburtstag.")
|
msg.set_content("Heute hat niemand aus deinen Kontakten Geburtstag.")
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {len(birthdays)}"
|
if len(birthdays) == 1:
|
||||||
|
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {birthdays[0]['full_name']}"
|
||||||
|
else:
|
||||||
|
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {len(birthdays)}"
|
||||||
|
|
||||||
plain_lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""]
|
plain_lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""]
|
||||||
for b in birthdays:
|
for b in birthdays:
|
||||||
@@ -154,7 +157,7 @@ def build_message(account_name: str, birthdays: list[dict], target_date: date |
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="padding:16px;">
|
<td style="padding:16px;">
|
||||||
<h1 style="font-size:20px;font-weight:600;color:#222;margin:0;">Geburtstage heute</h1>
|
<h1 style="font-size:20px;font-weight:600;color:#222;margin:0;">Geburtstage heute</h1>
|
||||||
<p style="font-size:13px;color:#666;margin:8px 0 16px 0;">{today.strftime('%d.%m.%Y')} · {len(birthdays)} Kontakte</p>
|
<p style="font-size:13px;color:#666;margin:8px 0 16px 0;">{today.strftime('%d.%m.%Y')} · {len(birthdays)} Kontakt{"e" if len(birthdays) != 1 else ""}</p>
|
||||||
<table width="100%" cellpadding="0" cellspacing="0">
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
{cards_html}
|
{cards_html}
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -107,6 +107,23 @@ def main():
|
|||||||
last_sync = datetime.now(Config.TIMEZONE)
|
last_sync = datetime.now(Config.TIMEZONE)
|
||||||
next_mailer = next_run_time(MAIL_SEND_HOUR)
|
next_mailer = next_run_time(MAIL_SEND_HOUR)
|
||||||
|
|
||||||
|
if MAILER_ENABLED:
|
||||||
|
try:
|
||||||
|
today = datetime.now(Config.TIMEZONE).date()
|
||||||
|
with db.get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM birthday_mail_log WHERE sent_date = %s LIMIT 1",
|
||||||
|
(today,),
|
||||||
|
)
|
||||||
|
already_sent = cur.fetchone() is not None
|
||||||
|
if not already_sent and next_mailer.date() > today:
|
||||||
|
logger.info("Mailer fuer heute noch nicht gesendet — hole nach")
|
||||||
|
run_mailer()
|
||||||
|
next_mailer = next_run_time(MAIL_SEND_HOUR)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Pruefung/Nachholen des Mailers fehlgeschlagen")
|
||||||
|
|
||||||
while not _shutdown:
|
while not _shutdown:
|
||||||
now = datetime.now(Config.TIMEZONE)
|
now = datetime.now(Config.TIMEZONE)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user