Compare commits

..
10 Commits
Author SHA1 Message Date
stefankoelle e6a7d19948 feat: auto-link URLs in chat messages 2026-09-17 19:53:28 +02:00
stefankoelle 0594a86894 feat: show reactions on chat messages, fix infinite scroll
- Add reaction display (emoji + user) below each message bubble
- Move sentinel/loading/end elements inside scrollable chat container
- Use chatBox as IntersectionObserver root for proper scroll detection
- Increase chat container max-height to 600px
2026-09-17 19:51:45 +02:00
stefankoelle 43ce0bcfe1 fix: build full_name from parts in chat messages endpoint
- SELECT prefix, given_name, middle_name, family_name, suffix alongside full_name
- Apply db._build_full_name() fallback when full_name is NULL
- Document the pattern in AGENTS.md to prevent future regressions
2026-09-17 19:41:38 +02:00
stefankoelle 113527a4a0 fix: generic Chat-Archive URL placeholder, add CHATAPI env vars to docker-compose 2026-09-17 19:05:20 +02:00
stefankoelle 5f39d276d6 feat: Chat-Archive integration on contact detail page
- Add proxy endpoint GET /api/contacts/{id}/messages (forwards to Chat-Archive API)
- Add chat_sender_name field to Account config for sender matching
- Add Infinite Scroll chat section with Messenger-style bubbles
- Umlaut-normalized name comparison (ae/oe/ue/ss)
- Add CHATAPI_ENABLED/URL/KEY environment variables
- Update README.md, SPEC.md, AGENTS.md
2026-09-17 19:01:42 +02:00
stefankoelle 7e2b29a950 birthday mail fullname when only 1 contact 2026-08-22 10:09:51 +02:00
stefankoelle f307f103cf .gitignore 2026-08-14 23:50:41 +02:00
stefankoelle 3aa3d648b2 README.md 2026-08-14 23:48:48 +02:00
stefankoelle 4c743036d5 special search last updated 2026-08-11 20:32:32 +02:00
stefankoelle 619ea84c5f mailer test if today already sent 2026-08-11 20:29:29 +02:00
14 changed files with 515 additions and 38 deletions
+5
View File
@@ -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
+1
View File
@@ -8,3 +8,4 @@ __pycache__/
.DS_Store .DS_Store
*.log *.log
.vscode/ .vscode/
.idea/
+6
View File
@@ -0,0 +1,6 @@
title: "iCloud Contacts Sync"
emoji: "🔄"
category: code
subcategory: "Smart Home Apps"
status: active
stack: [Python, FastAPI, Uvicorn, Jinja2]
+14
View File
@@ -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
+74 -32
View File
@@ -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
[![Contact detail](docs/screenshot2_thumbnail.png)](docs/screenshot2.png) [![Contact detail](docs/screenshot2_thumbnail.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)
+29 -1
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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,
}, },
) )
+1
View File
@@ -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>
+273
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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(
+4 -1
View File
@@ -69,6 +69,9 @@ 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
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)}" msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {len(birthdays)}"
plain_lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""] plain_lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""]
@@ -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>
+17
View File
@@ -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)