Compare commits

...
20 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
stefankoelle debaf7dd62 related contact detail 2026-08-10 23:55:23 +02:00
stefankoelle 00e4e54599 related fix parser 2026-08-10 23:29:44 +02:00
stefankoelle 3edcc99c61 related contact details 2026-08-10 23:20:42 +02:00
stefankoelle 7b1acbb8bb related mapping 2026-08-10 22:54:44 +02:00
Stefan Koelle af124d803f Merge pull request #2 from skoelle/renovate/major-github-actions
chore(deps): update github actions (major)
2026-08-10 20:24:06 +02:00
renovate[bot] a05a8da3f1 chore(deps): update github actions 2026-08-10 18:20:26 +00:00
stefankoelle e966157f15 fix: update TemplateResponse calls for Starlette 0.31+ compatibility 2026-08-10 20:09:24 +02:00
stefankoelle 2dd93d8d68 chore: update dependencies and add Renovate config 2026-08-10 19:52:58 +02:00
stefankoelle fcaa74f774 demo showcase 2026-08-10 17:42:13 +02:00
stefankoelle 28bfbc01bf screenshot 2026-08-10 17:35:24 +02:00
26 changed files with 1090 additions and 66 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
+5 -5
View File
@@ -18,21 +18,21 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Extract metadata - name: Extract metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
@@ -40,7 +40,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
push: true push: true
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v7
with: with:
python-version: "3.12" python-version: "3.12"
+3
View File
@@ -1,8 +1,11 @@
.env .env
config/accounts.json config/accounts.json
.venv/ .venv/
.venv-demo/
demo.db
__pycache__/ __pycache__/
*.pyc *.pyc
.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]
+26
View File
@@ -37,6 +37,8 @@ config/
accounts.json — Per-account credentials (NOT in git, volume-mounted) accounts.json — Per-account credentials (NOT in git, volume-mounted)
docker/ docker/
entrypoint.sh — Starts scheduler.py (or exec's custom command) entrypoint.sh — Starts scheduler.py (or exec's custom command)
demo.py — SQLite demo app with fake contacts (screenshot/showcase)
demo.sh — Launches demo: creates venv, installs deps, starts server
``` ```
## Key Files ## Key Files
@@ -50,6 +52,8 @@ docker/
| `src/db.py` | All MariaDB queries | | `src/db.py` | All MariaDB queries |
| `sql/schema.sql` | Canonical schema definition | | `sql/schema.sql` | Canonical schema definition |
| `.env.example` | All supported environment variables | | `.env.example` | All supported environment variables |
| `demo.py` | SQLite demo app with fake contacts (screenshot/showcase) |
| `demo.sh` | Launches demo: creates venv, installs deps, starts server |
## Commands ## Commands
@@ -83,6 +87,14 @@ docker compose build
docker compose up -d docker compose up -d
``` ```
### Run demo (no MariaDB needed)
```bash
./demo.sh
```
Starts SQLite-based demo on `0.0.0.0:8000` with 6 fake contacts.
## Code Conventions ## Code Conventions
- All source in `src/`, single package, no `setup.py`/`pyproject.toml`. - All source in `src/`, single package, no `setup.py`/`pyproject.toml`.
@@ -110,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
@@ -132,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
+104 -28
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
@@ -6,17 +6,21 @@ Synct alle Kontakte mehrerer iCloud-Accounts per CardDAV Delta-Sync
Geburtstage. Für den vollständigen technischen Hintergrund siehe Geburtstage. Für den vollständigen technischen Hintergrund siehe
[SPEC.md](./SPEC.md). [SPEC.md](./SPEC.md).
## Voraussetzungen [![Dashboard](docs/screenshot1_thumbnail.png)](docs/screenshot1.png)
[![Contact detail](docs/screenshot2_thumbnail.png)](docs/screenshot2.png)
- Eine oder mehrere Apple-IDs mit aktivierter Zwei-Faktor-Authentifizierung.
- Für jede Apple-ID ein app-spezifisches Passwort. ## 📋 Voraussetzungen
- Eine erreichbare MariaDB-Instanz mit vorbereiteter Datenbank.
- Ein SMTP-Relay (z. B. dein Mailprovider oder ein lokaler Relay) für - 🔐 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. 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:
@@ -25,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
@@ -36,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:
@@ -50,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
@@ -62,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
@@ -72,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
@@ -83,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
@@ -112,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.
@@ -129,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
@@ -140,7 +146,37 @@ python3 sync.py
python3 mailer.py python3 mailer.py
``` ```
## 10. CI/CD ## 🎬 10. Demo-Modus (Screenshot/Showcase)
Lokale Demo mit SQLite-Backend und Fake-Kontakten, ohne MariaDB,
Apple-IDs oder Docker. Zeigt Dashboard, Kontakt-Detailseite und
Gruppen-Übersicht mit farbigen UI-Avatar-Bildern.
### Starten
```bash
▶️ ./demo.sh
```
Das Script erstellt automatisch ein virtuelles Umfeld
(`.venv-demo/`), installiert die Dependencies und startet den
Server auf `0.0.0.0:8000`.
### 👀 Was angezeigt wird
- Dashboard mit 6 Kontakten, Geburtstagen der nächsten 7 Tage,
2 Gruppen ("Familie", "Arbeit") und成功stem Sync-Status
- Kontakt-Detailseite mit E-Mail, Telefon, Adresse, Foto
- Farbige Initialen-Avatare via ui-avatars.com
### 🔧 Technisches
- SQLite-Datenbank (`demo.db`) wird bei jedem Start frisch angelegt
- Kein `.env`, kein `accounts.json` nötig
- Templates und CSS werden aus `src/api/` wiederverwendet
- `.venv-demo/` und `demo.db` sind in `.gitignore` eingetragen
## ⚡ 11. CI/CD
- Jeder Push auf `main` baut automatisch ein neues Image und pusht es - 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`.
@@ -151,14 +187,14 @@ python3 mailer.py
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
@@ -177,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
@@ -190,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
@@ -216,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`:
@@ -233,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
@@ -242,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 |
|---------|------|--------------| |---------|------|--------------|
@@ -260,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.
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env python3
"""Lokale Demo-App: SQLite-Backend mit Fake-Kontakten für Dashboard + Kontakt-Detail."""
import json
import logging
import secrets
import sqlite3
from contextlib import contextmanager
from datetime import date, datetime, timedelta
from pathlib import Path
from urllib.parse import quote_plus
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year
logging.basicConfig(level="INFO", format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("demo")
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "demo.db"
SRC_DIR = BASE_DIR / "src"
app = FastAPI(title="iCloud Contacts Sync Demo", version="1.0.0")
app.add_middleware(SessionMiddleware, secret_key=secrets.token_hex(32), session_cookie="ics_session")
app.mount("/static", StaticFiles(directory=str(SRC_DIR / "api" / "static")), name="static")
templates = Jinja2Templates(directory=str(SRC_DIR / "api" / "templates"))
templates.env.filters["urlquote"] = lambda s: quote_plus(s or "")
templates.env.filters["fmt_birthday"] = fmt_birthday_short
templates.env.filters["fmt_age"] = fmt_birthday_age
templates.env.filters["has_year"] = lambda b: not is_unknown_year(b)
DEMO_USER = "demo"
DEMO_ACCOUNT = "demo-user"
def _fmt_ts(ts: str | None) -> str | None:
if not ts:
return None
try:
dt = datetime.fromisoformat(ts)
return dt.strftime("%d.%m.%Y %H:%M:%S")
except (ValueError, TypeError):
return ts
@contextmanager
def get_connection():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
try:
yield conn
finally:
conn.close()
def _build_full_name(row: dict) -> str | None:
parts = [
row.get("given_name"),
row.get("middle_name"),
row.get("family_name"),
]
return " ".join(p for p in parts if p) or None
def _row_to_contact_out(row: dict) -> dict:
row = dict(row)
for field in ("emails", "phones", "addresses", "urls", "social_profiles", "categories"):
raw = row.get(field)
row[field] = json.loads(raw) if raw else []
if row.get("birthday") and isinstance(row["birthday"], str):
row["birthday"] = date.fromisoformat(row["birthday"])
if not row.get("full_name"):
row["full_name"] = _build_full_name(row)
return row
def init_db():
with get_connection() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account TEXT NOT NULL,
uid TEXT NOT NULL,
full_name TEXT, given_name TEXT, family_name TEXT,
middle_name TEXT, prefix TEXT, suffix TEXT,
organization TEXT, job_title TEXT,
birthday TEXT, notes TEXT,
emails TEXT, phones TEXT, addresses TEXT,
urls TEXT, social_profiles TEXT, categories TEXT,
photo_url TEXT,
raw_vcard TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(account, uid)
);
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account TEXT NOT NULL,
uid TEXT NOT NULL,
name TEXT,
UNIQUE(account, uid)
);
CREATE TABLE IF NOT EXISTS group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER REFERENCES groups(id),
member_uid TEXT NOT NULL,
UNIQUE(group_id, member_uid)
);
CREATE TABLE IF NOT EXISTS sync_runs (
id TEXT PRIMARY KEY,
account TEXT NOT NULL,
sync_type TEXT NOT NULL DEFAULT 'delta',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT,
status TEXT NOT NULL DEFAULT 'running',
contacts_upserted INTEGER,
contacts_deleted INTEGER,
error_message TEXT
);
""")
def seed_data():
with get_connection() as conn:
count = conn.execute("SELECT COUNT(*) FROM contacts").fetchone()[0]
if count > 0:
return
today = date.today()
def _avatar(name: str, bg: str = "0066cc") -> str:
from urllib.parse import quote
return f"https://ui-avatars.com/api/?name={quote(name)}&background={bg}&color=fff&size=160&bold=true"
contacts = [
{
"uid": "demo-001", "full_name": "Erika Musterfrau",
"given_name": "Erika", "family_name": "Musterfrau",
"organization": "Muster GmbH", "job_title": "Geschäftsführerin",
"birthday": "1985-03-15",
"photo_url": _avatar("Erika Musterfrau", "0066cc"),
"emails": json.dumps([{"type": "work", "value": "erika@muster.de"}, {"type": "home", "value": "erika.privat@mail.de"}]),
"phones": json.dumps([{"type": "work", "value": "+49 30 1234567"}, {"type": "mobile", "value": "+49 170 1234567"}]),
"addresses": json.dumps([{"type": "work", "street": "Berliner Str. 1", "zip": "10115", "city": "Berlin", "region": "Berlin", "country": "Deutschland"}]),
},
{
"uid": "demo-002", "full_name": "Thomas Testermann",
"given_name": "Thomas", "family_name": "Testermann",
"organization": "Test AG", "job_title": "Entwickler",
"birthday": "1990-07-22",
"photo_url": _avatar("Thomas Testermann", "28a745"),
"emails": json.dumps([{"type": "work", "value": "thomas@test.de"}]),
"phones": json.dumps([{"type": "mobile", "value": "+49 171 9876543"}]),
"addresses": json.dumps([{"type": "home", "street": "Musterweg 5", "zip": "80331", "city": "München", "region": "Bayern", "country": "Deutschland"}]),
},
{
"uid": "demo-003", "full_name": "Anna Beispiel",
"given_name": "Anna", "family_name": "Beispiel",
"organization": "Beispiel & Partner", "job_title": "Designerin",
"birthday": "1992-11-08",
"photo_url": _avatar("Anna Beispiel", "dc3545"),
"emails": json.dumps([{"type": "home", "value": "anna@beispiel.de"}]),
"phones": json.dumps([{"type": "mobile", "value": "+49 172 5551234"}]),
"addresses": json.dumps([{"type": "home", "street": "Gartenstr. 12", "zip": "50667", "city": "Köln", "region": "Nordrhein-Westfalen", "country": "Deutschland"}]),
},
{
"uid": "demo-004", "full_name": "Sabine Mustermann",
"given_name": "Sabine", "family_name": "Mustermann",
"organization": "", "job_title": "",
"birthday": f"1997-08-{(today + timedelta(days=5)).day:02d}",
"photo_url": _avatar("Sabine Mustermann", "fd7e14"),
"emails": json.dumps([{"type": "home", "value": "sabine@web.de"}]),
"phones": json.dumps([{"type": "home", "value": "+49 721 555999"}]),
"addresses": json.dumps([{"type": "home", "street": "Waldweg 3", "zip": "70173", "city": "Stuttgart", "region": "Baden-Württemberg", "country": "Deutschland"}]),
},
{
"uid": "demo-005", "full_name": "Peter Beispiel",
"given_name": "Peter", "family_name": "Beispiel",
"organization": "Beispiel & Partner", "job_title": "Partner",
"birthday": "1960-05-01",
"photo_url": _avatar("Peter Beispiel", "6f42c1"),
"emails": json.dumps([{"type": "work", "value": "peter@beispiel.de"}]),
"phones": json.dumps([{"type": "work", "value": "+49 721 555888"}, {"type": "mobile", "value": "+49 175 1112233"}]),
"addresses": json.dumps([{"type": "work", "street": "Hauptstr. 42", "zip": "70173", "city": "Stuttgart", "region": "Baden-Württemberg", "country": "Deutschland"}]),
},
{
"uid": "demo-006", "full_name": "Max Demo",
"given_name": "Max", "family_name": "Demo",
"organization": "Demo Inc.", "job_title": "Projektmanager",
"birthday": f"1991-08-{(today + timedelta(days=3)).day:02d}",
"photo_url": _avatar("Max Demo", "17a2b8"),
"emails": json.dumps([{"type": "work", "value": "max@demo.de"}, {"type": "home", "value": "max.privat@demo.de"}]),
"phones": json.dumps([{"type": "mobile", "value": "+49 176 4445566"}]),
"addresses": json.dumps([{"type": "home", "street": "Demoallee 7", "zip": "10115", "city": "Berlin", "region": "Berlin", "country": "Deutschland"}]),
},
]
for c in contacts:
conn.execute(
"""INSERT INTO contacts (account, uid, full_name, given_name, family_name,
organization, job_title, birthday, photo_url, emails, phones, addresses)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(DEMO_ACCOUNT, c["uid"], c["full_name"], c["given_name"], c["family_name"],
c["organization"], c["job_title"], c["birthday"], c["photo_url"],
c["emails"], c["phones"], c["addresses"]),
)
groups = [
("demo-grp-001", "Familie"),
("demo-grp-002", "Arbeit"),
]
for uid, name in groups:
conn.execute(
"INSERT INTO groups (account, uid, name) VALUES (?, ?, ?)",
(DEMO_ACCOUNT, uid, name),
)
family_id = conn.execute("SELECT id FROM groups WHERE uid = ?", ("demo-grp-001",)).fetchone()[0]
work_id = conn.execute("SELECT id FROM groups WHERE uid = ?", ("demo-grp-002",)).fetchone()[0]
for uid in ["demo-001", "demo-004", "demo-005"]:
conn.execute("INSERT INTO group_members (group_id, member_uid) VALUES (?, ?)", (family_id, uid))
for uid in ["demo-001", "demo-002", "demo-003", "demo-005"]:
conn.execute("INSERT INTO group_members (group_id, member_uid) VALUES (?, ?)", (work_id, uid))
conn.execute(
"""INSERT INTO sync_runs (id, account, sync_type, started_at, finished_at, status, contacts_upserted, contacts_deleted)
VALUES (?, ?, ?, datetime('now', '-2 hours'), datetime('now', '-1 hour'), 'success', 6, 0)""",
("demo-sync-001", DEMO_ACCOUNT, "delta"),
)
conn.commit()
logger.info("Demo-Daten erstellt: 6 Kontakte, 2 Gruppen")
@app.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
with get_connection() as conn:
contact_count = conn.execute("SELECT COUNT(*) AS total FROM contacts").fetchone()["total"]
today = date.today()
rows = conn.execute(
"""SELECT id, full_name, given_name, middle_name, family_name,
organization, birthday, photo_url
FROM contacts
WHERE birthday IS NOT NULL
ORDER BY birthday""",
).fetchall()
upcoming = []
cutoff = today + timedelta(days=7)
for r in rows:
bday = date.fromisoformat(r["birthday"])
bday_this_year = bday.replace(year=today.year)
if today <= bday_this_year <= cutoff:
d = dict(r)
d["birthday"] = bday
if not d.get("full_name"):
d["full_name"] = _build_full_name(d)
upcoming.append(d)
groups = [dict(g) for g in conn.execute(
"SELECT id, name, uid FROM groups ORDER BY name"
).fetchall()]
last_sync_row = conn.execute(
"""SELECT id, sync_type, started_at, finished_at, status,
contacts_upserted, contacts_deleted, error_message
FROM sync_runs ORDER BY started_at DESC LIMIT 1"""
).fetchone()
last_sync = dict(last_sync_row) if last_sync_row else None
if last_sync:
last_sync["started_at"] = _fmt_ts(last_sync["started_at"])
last_sync["finished_at"] = _fmt_ts(last_sync["finished_at"])
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"current_user": DEMO_USER,
"is_admin": True,
"show_all": False,
"account_name": DEMO_ACCOUNT,
"contact_count": contact_count,
"upcoming_birthdays": upcoming,
"last_sync": last_sync,
"last_sync_with_changes": None,
"groups": groups,
"current_year": today.year,
"today": today,
},
)
@app.get("/contacts/{contact_id}", response_class=HTMLResponse)
def contact_detail(request: Request, contact_id: int):
with get_connection() as conn:
row = conn.execute(
"""SELECT id, account, uid, full_name, given_name, middle_name, family_name,
organization, job_title, birthday, notes, photo_url,
emails, phones, addresses, urls, social_profiles, categories, updated_at
FROM contacts WHERE id = ?""",
(contact_id,),
).fetchone()
if not row:
return HTMLResponse("Kontakt nicht gefunden", status_code=404)
contact = _row_to_contact_out(row)
groups = [dict(g) for g in conn.execute(
"""SELECT g.id, g.name, g.uid
FROM groups g
JOIN group_members gm ON gm.group_id = g.id
WHERE gm.member_uid = ?
ORDER BY g.name""",
(row["uid"],),
).fetchall()]
return templates.TemplateResponse(
"contact.html",
{
"request": request,
"current_user": DEMO_USER,
"is_admin": True,
"show_all": False,
"contact": contact,
"groups": groups,
"search": "",
"custom_links": [],
},
)
@app.get("/search", response_class=HTMLResponse)
def search_redirect():
return HTMLResponse(
'<!DOCTYPE html><html><body style="font-family:sans-serif;padding:2rem">'
'<h2>Demo-Modus</h2><p><a href="/">← Zurück zum Dashboard</a></p>'
'</body></html>',
status_code=302,
headers={"Location": "/"},
)
@app.get("/api/health")
def health():
return {"status": "ok", "mode": "demo"}
if __name__ == "__main__":
import uvicorn
init_db()
seed_data()
logger.info("Starte Demo-Server auf http://127.0.0.1:8000")
uvicorn.run(app, host="0.0.0.0", port=8000)
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Startet die lokale Demo-App mit SQLite-Backend und Fake-Kontakten.
# Erstellt automatisch ein virtuelles Umfeld, wenn nicht vorhanden.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv-demo"
if [ ! -d "$VENV_DIR" ]; then
echo "Erstelle virtuelles Umfeld in $VENV_DIR ..."
python3 -m venv "$VENV_DIR"
fi
echo "Installiere Dependencies ..."
"$VENV_DIR/bin/pip" install -q -r "$SCRIPT_DIR/requirements.txt"
echo "Starte Demo-Server auf http://127.0.0.1:8000"
PYTHONPATH="$SCRIPT_DIR/src" exec "$VENV_DIR/bin/python" "$SCRIPT_DIR/demo.py"
+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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"schedule": ["before 6am on Monday"],
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
},
{
"matchUpdateTypes": ["major"],
"automerge": false,
"labels": ["major-update"]
},
{
"matchManagers": ["github-actions"],
"groupName": "GitHub Actions",
"automerge": true
},
{
"matchManagers": ["dockerfile"],
"groupName": "Docker",
"automerge": false
}
]
}
+9 -9
View File
@@ -1,10 +1,10 @@
requests==2.32.3 requests==2.34.2
vobject==0.9.6.1 vobject==0.9.9
PyMySQL==1.1.1 PyMySQL==1.2.0
lxml==5.3.0 lxml==5.4.0
python-dotenv==1.0.1 python-dotenv==1.2.2
fastapi==0.115.0 fastapi==0.141.1
uvicorn[standard]==0.32.0 uvicorn[standard]==0.52.1
jinja2==3.1.4 jinja2==3.1.6
pydantic==2.9.2 pydantic==2.13.4
itsdangerous==2.2.0 itsdangerous==2.2.0
+98 -16
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
@@ -56,7 +57,7 @@ def _fmt_ts(dt) -> str | None:
def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict: def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict:
row = dict(row) row = dict(row)
for field in ["emails", "phones", "addresses", "urls", "social_profiles", "categories"]: for field in ["emails", "phones", "addresses", "urls", "social_profiles", "related_names", "categories"]:
raw = row.get(field) raw = row.get(field)
row[field] = json.loads(raw) if raw else [] row[field] = json.loads(raw) if raw else []
if not row.get("full_name"): if not row.get("full_name"):
@@ -66,6 +67,25 @@ def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict
return row return row
def _enrich_related_names(conn, contact: dict) -> None:
related = contact.get("related_names", [])
if not related:
return
names = list({r["value"] for r in related if r.get("value")})
if not names:
return
resolved = db.resolve_related_names(conn, contact["account"], names)
for r in related:
name = r.get("value", "")
info = resolved.get(name)
if info:
r["id"] = info["id"]
r["name"] = info["name"]
else:
r["id"] = None
r["name"] = name
def _account_filter_clause(account_name: str | None) -> tuple[str, list]: def _account_filter_clause(account_name: str | None) -> tuple[str, list]:
if account_name is None: if account_name is None:
return "", [] return "", []
@@ -134,7 +154,7 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization, f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization,
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, categories, updated_at job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, related_names, categories, updated_at
FROM contacts {where_clause} {id_clause}""", FROM contacts {where_clause} {id_clause}""",
params + [contact_id], params + [contact_id],
) )
@@ -146,7 +166,9 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
groups = db.get_groups_for_contact(conn, row["account"], row["uid"]) groups = db.get_groups_for_contact(conn, row["account"], row["uid"])
group_names = [g["name"] for g in groups if g.get("name")] group_names = [g["name"] for g in groups if g.get("name")]
return _row_to_contact_out(row, group_names=group_names) contact = _row_to_contact_out(row, group_names=group_names)
_enrich_related_names(conn, contact)
return contact
@app.get("/api/contacts/birthdays/today", response_model=list[ContactOut]) @app.get("/api/contacts/birthdays/today", response_model=list[ContactOut])
@@ -190,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)
@@ -363,9 +437,9 @@ def web_dashboard(
last_sync_with_changes = None last_sync_with_changes = None
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard.html", "dashboard.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"is_admin": is_admin, "is_admin": is_admin,
"show_all": show_all, "show_all": show_all,
@@ -392,9 +466,9 @@ def web_admin(
show_all = request.session.get("show_all", False) show_all = request.session.get("show_all", False)
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"admin.html", "admin.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"show_all": show_all, "show_all": show_all,
}, },
@@ -428,9 +502,9 @@ def admin_test_send(
Config.validate_mailer() Config.validate_mailer()
except RuntimeError as exc: except RuntimeError as exc:
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"admin.html", "admin.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"show_all": request.session.get("show_all", False), "show_all": request.session.get("show_all", False),
"error": str(exc), "error": str(exc),
@@ -442,9 +516,9 @@ def admin_test_send(
mail_accounts = [a for a in accounts if a.birthday_mail_to] mail_accounts = [a for a in accounts if a.birthday_mail_to]
if not mail_accounts: if not mail_accounts:
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"admin.html", "admin.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"show_all": request.session.get("show_all", False), "show_all": request.session.get("show_all", False),
"error": "Keine Accounts mit birthday_mail_to konfiguriert", "error": "Keine Accounts mit birthday_mail_to konfiguriert",
@@ -456,9 +530,9 @@ def admin_test_send(
target = db.get_most_common_birthday(conn) target = db.get_most_common_birthday(conn)
if target is None: if target is None:
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"admin.html", "admin.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"show_all": request.session.get("show_all", False), "show_all": request.session.get("show_all", False),
"error": "Keine Kontakte mit Geburtstag in der Datenbank", "error": "Keine Kontakte mit Geburtstag in der Datenbank",
@@ -482,9 +556,9 @@ def admin_test_send(
if errors: if errors:
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"admin.html", "admin.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"show_all": request.session.get("show_all", False), "show_all": request.session.get("show_all", False),
"error": f"Versand fehlgeschlagen: {'; '.join(errors)}", "error": f"Versand fehlgeschlagen: {'; '.join(errors)}",
@@ -493,9 +567,9 @@ def admin_test_send(
) )
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"admin.html", "admin.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"show_all": request.session.get("show_all", False), "show_all": request.session.get("show_all", False),
"success": f"Test-Mails für {target.strftime('%d.%m.%Y')} gesendet ({sent_count} Accounts).", "success": f"Test-Mails für {target.strftime('%d.%m.%Y')} gesendet ({sent_count} Accounts).",
@@ -515,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:
@@ -524,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:
@@ -535,9 +611,9 @@ def web_search_special(
row["full_name"] = db._build_full_name(row) row["full_name"] = db._build_full_name(row)
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"index.html", "index.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"is_admin": is_admin, "is_admin": is_admin,
"show_all": show_all, "show_all": show_all,
@@ -595,9 +671,9 @@ def web_search(
row["full_name"] = db._build_full_name(row) row["full_name"] = db._build_full_name(row)
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"index.html", "index.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"is_admin": is_admin, "is_admin": is_admin,
"show_all": show_all, "show_all": show_all,
@@ -625,7 +701,7 @@ def web_contact(
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization, f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization,
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, categories, updated_at job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, related_names, categories, updated_at
FROM contacts {where_clause} {id_clause}""", FROM contacts {where_clause} {id_clause}""",
params + [contact_id], params + [contact_id],
) )
@@ -638,7 +714,8 @@ def web_contact(
groups = db.get_groups_for_contact(conn, row["account"], row["uid"]) groups = db.get_groups_for_contact(conn, row["account"], row["uid"])
group_names = [g["name"] for g in groups if g.get("name")] group_names = [g["name"] for g in groups if g.get("name")]
contact = _row_to_contact_out(row, group_names=group_names) contact = _row_to_contact_out(row, group_names=group_names)
_enrich_related_names(conn, contact)
homecity = "" homecity = ""
workcity = "" workcity = ""
@@ -653,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 = []
@@ -674,9 +753,9 @@ def web_contact(
resolved_links.append({"label": link["label"], "url": url}) resolved_links.append({"label": link["label"], "url": url})
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"contact.html", "contact.html",
{ {
"request": request,
"current_user": current_user, "current_user": current_user,
"is_admin": is_admin, "is_admin": is_admin,
"show_all": show_all, "show_all": show_all,
@@ -684,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
@@ -25,6 +25,7 @@ class ContactOut(BaseModel):
addresses: list addresses: list
urls: list urls: list
social_profiles: list social_profiles: list
related_names: list
categories: list categories: list
groups: list[str] = [] groups: list[str] = []
updated_at: str updated_at: str
+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>
+289
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>
@@ -436,6 +576,22 @@
</div> </div>
{% endif %} {% endif %}
{% if contact.related_names %}
<div class="section">
<div class="section-title">Beziehungen</div>
<div class="tiles-grid">
{% for rn in contact.related_names %}
<div class="tile">
<div class="tile-label">{{ rn.type }}</div>
<div class="tile-value">
{% if rn.id %}<a href="/contacts/{{ rn.id }}">{{ rn.name }}</a>{% else %}{{ rn.name }}{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if contact.categories %} {% if contact.categories %}
<div class="section"> <div class="section">
<div class="section-title">Kategorien</div> <div class="section-title">Kategorien</div>
@@ -464,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 %}
@@ -485,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
+43
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(
@@ -383,6 +401,31 @@ def get_groups_for_contact(conn, account: str, member_uid: str) -> list[dict]:
return cur.fetchall() return cur.fetchall()
def resolve_related_names(conn, account: str, names: list[str]) -> dict[str, dict]:
if not names:
return {}
conditions = []
params: list = [account]
for name in names:
conditions.append("full_name = %s")
params.append(name)
parts = name.strip().split()
if len(parts) >= 2:
conditions.append("(given_name = %s AND family_name = %s)")
params.extend([parts[0], parts[-1]])
placeholders = " OR ".join(conditions)
with conn.cursor() as cur:
cur.execute(
f"SELECT full_name, id, given_name, family_name FROM contacts WHERE account = %s AND ({placeholders})",
params,
)
result = {}
for row in cur.fetchall():
fn = row["full_name"] or f"{row.get('given_name') or ''} {row.get('family_name') or ''}".strip()
result[fn] = {"id": row["id"], "name": fn}
return result
def get_group_count(conn, account: str | None) -> int: def get_group_count(conn, account: str | None) -> int:
where_clause, params = _account_filter_clause(account) where_clause, params = _account_filter_clause(account)
with conn.cursor() as cur: with conn.cursor() as cur:
+5 -2
View File
@@ -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>
+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)
+33 -1
View File
@@ -124,6 +124,38 @@ def parse_vcard(raw_text: str, account: str, etag: str | None = None) -> dict |
categories = [c.strip() for c in vcard.categories.value] if hasattr(vcard, "categories") else [] categories = [c.strip() for c in vcard.categories.value] if hasattr(vcard, "categories") else []
_APPLE_LABEL_MAP = {
"father": "Vater", "mother": "Mutter", "parent": "Elternteil",
"son": "Sohn", "daughter": "Tochter", "child": "Kind",
"spouse": "Ehepartner", "wife": "Ehefrau", "husband": "Ehemann",
"partner": "Partner", "sibling": "Geschwister", "brother": "Bruder", "sister": "Schwester",
"friend": "Freund", "femalefriend": "Freundin", "malefriend": "Freund",
"colleague": "Kollege", "coworker": "Mitarbeiter",
"manager": "Vorgesetzter", "assistant": "Assistent",
"related": "Verwandter", "other": "Sonstige",
}
related_names = []
abrelated = vcard.contents.get("x-abrelatednames", [])
ablabels = vcard.contents.get("x-ablabel", [])
labels_by_group = {}
for lbl in ablabels:
labels_by_group[getattr(lbl, "group", "")] = lbl.value
for r in abrelated:
group = getattr(r, "group", "")
raw_label = labels_by_group.get(group, "")
if raw_label.startswith("_$!<") and raw_label.endswith(">!$_"):
key = raw_label[4:-4].lower()
label = _APPLE_LABEL_MAP.get(key, key.capitalize())
elif raw_label:
label = raw_label
else:
label = "Sonstige"
related_names.append({
"type": label,
"value": r.value if r.value else "",
})
photo_url = None photo_url = None
photo_base64 = None photo_base64 = None
if hasattr(vcard, "photo"): if hasattr(vcard, "photo"):
@@ -168,7 +200,7 @@ def parse_vcard(raw_text: str, account: str, etag: str | None = None) -> dict |
"addresses": json.dumps(addresses, ensure_ascii=False), "addresses": json.dumps(addresses, ensure_ascii=False),
"urls": json.dumps(urls, ensure_ascii=False), "urls": json.dumps(urls, ensure_ascii=False),
"social_profiles": json.dumps(social_profiles, ensure_ascii=False), "social_profiles": json.dumps(social_profiles, ensure_ascii=False),
"related_names": json.dumps([], ensure_ascii=False), "related_names": json.dumps(related_names, ensure_ascii=False),
"categories": json.dumps(categories, ensure_ascii=False), "categories": json.dumps(categories, ensure_ascii=False),
"raw_vcard": raw_text, "raw_vcard": raw_text,
"source": "icloud", "source": "icloud",