mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-17 15:30:24 +00:00
birthday mail for each account
This commit is contained in:
@@ -18,7 +18,6 @@ SMTP_USER=mailer@example.de
|
|||||||
SMTP_PASSWORD=change-me
|
SMTP_PASSWORD=change-me
|
||||||
SMTP_USE_TLS=true
|
SMTP_USE_TLS=true
|
||||||
MAIL_FROM=contacts-sync@example.de
|
MAIL_FROM=contacts-sync@example.de
|
||||||
MAIL_TO=du@example.de
|
|
||||||
MAIL_SEND_HOUR=7
|
MAIL_SEND_HOUR=7
|
||||||
|
|
||||||
# --- Web-Ansicht / API (nur relevant für den zweiten Container) ---
|
# --- Web-Ansicht / API (nur relevant für den zweiten Container) ---
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ vim config/accounts.json
|
|||||||
```
|
```
|
||||||
|
|
||||||
Trage für jede Apple-ID einen Eintrag mit eindeutigem `name`,
|
Trage für jede Apple-ID einen Eintrag mit eindeutigem `name`,
|
||||||
`apple_email`, `apple_app_password` und `authelia_user` ein. Optional
|
`apple_email`, `apple_app_password`, `authelia_user` und (optional)
|
||||||
kann pro Account eine `healthcheck_url` konfiguriert werden, die nach
|
`birthday_mail_to` ein. Optional kann pro Account eine `healthcheck_url`
|
||||||
jedem erfolgreichen Sync aufgerufen wird (z.B. für Uptime-Monitoring).
|
konfiguriert werden, die nach jedem erfolgreichen Sync aufgerufen wird
|
||||||
Diese Datei
|
(z.B. für Uptime-Monitoring). 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.
|
||||||
|
|
||||||
@@ -58,7 +58,9 @@ vim .env
|
|||||||
```
|
```
|
||||||
|
|
||||||
Trage mindestens `MARIADB_USER`, `MARIADB_PASSWORD` sowie (falls du den
|
Trage mindestens `MARIADB_USER`, `MARIADB_PASSWORD` sowie (falls du den
|
||||||
Mailer nutzen willst) `SMTP_HOST`, `MAIL_FROM` und `MAIL_TO` ein.
|
Mailer nutzen willst) `SMTP_HOST` und `MAIL_FROM` ein. Die
|
||||||
|
Empfänger-Adresse wird pro Account in `accounts.json` unter
|
||||||
|
`birthday_mail_to` konfiguriert.
|
||||||
|
|
||||||
## 5. Image beziehen
|
## 5. Image beziehen
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ SELECT account, sync_token, updated_at FROM sync_state;
|
|||||||
Versandhistorie der Geburtstagsmails:
|
Versandhistorie der Geburtstagsmails:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT sent_date, contacts_count, sent_at FROM birthday_mail_log
|
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;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -114,16 +116,18 @@ ORDER BY sent_date DESC LIMIT 10;
|
|||||||
|
|
||||||
- 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.
|
||||||
- Versendet eine HTML-E-Mail mit stylisierten Geburtstagskarten und
|
- Versendet pro Account mit gesetztem `birthday_mail_to` eine eigene
|
||||||
Links zur Kontakt-Detailseite (falls `WEB_URL` gesetzt).
|
HTML-E-Mail mit stylisierten Geburtstagskarten und Links zur
|
||||||
|
Kontakt-Detailseite (falls `WEB_URL` gesetzt).
|
||||||
- Über `MAILER_ENABLED=false` lässt sich der Mailer ganz abschalten,
|
- Über `MAILER_ENABLED=false` lässt sich der Mailer ganz abschalten,
|
||||||
ohne den Kontakt-Sync zu beeinträchtigen.
|
ohne den Kontakt-Sync zu beeinträchtigen.
|
||||||
- Manueller Testlauf im laufenden Container:
|
- Manueller Testlauf im laufenden Container:
|
||||||
```
|
```
|
||||||
docker exec -it icloud-contacts-sync python3 /app/mailer.py
|
docker exec -it icloud-contacts-sync python3 /app/mailer.py
|
||||||
```
|
```
|
||||||
- Ein zweiter manueller Lauf am selben Tag versendet keine zweite Mail,
|
- Ein zweiter manueller Lauf am selben Tag versendet keine zweite Mail
|
||||||
solange bereits ein Eintrag in `birthday_mail_log` für heute existiert.
|
pro Account, solange bereits ein Eintrag in `birthday_mail_log` für
|
||||||
|
heute und diesen Account existiert.
|
||||||
|
|
||||||
## 9. Lokale Entwicklung (ohne Docker)
|
## 9. Lokale Entwicklung (ohne Docker)
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ 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", "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" },
|
||||||
{ "name": "partner", "apple_email": "partner@icloud.com", "apple_app_password": "yyyy-yyyy-yyyy-yyyy", "authelia_user": "pmustermann" }
|
{ "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"]
|
||||||
}
|
}
|
||||||
@@ -96,8 +96,8 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1:
|
|||||||
aktuellen `sync_token`.
|
aktuellen `sync_token`.
|
||||||
- `sync_runs` erweitert um `account`, `sync_type`
|
- `sync_runs` erweitert um `account`, `sync_type`
|
||||||
(`initial`/`delta`), `contacts_upserted`, `contacts_deleted`.
|
(`initial`/`delta`), `contacts_upserted`, `contacts_deleted`.
|
||||||
- Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag, an dem
|
- Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag und Account,
|
||||||
erfolgreich eine Geburtstagsmail versendet wurde, verhindert
|
an dem erfolgreich eine Geburtstagsmail versendet wurde, verhindert
|
||||||
Doppelversand bei mehrfachem Container-Neustart am selben Tag.
|
Doppelversand bei mehrfachem Container-Neustart am selben Tag.
|
||||||
- Neue Tabellen `groups` und `group_members`: Speichert
|
- Neue Tabellen `groups` und `group_members`: Speichert
|
||||||
iCloud-Kontaktgruppen (vCards mit `X-ADDRESSBOOKSERVER-KIND:group`)
|
iCloud-Kontaktgruppen (vCards mit `X-ADDRESSBOOKSERVER-KIND:group`)
|
||||||
@@ -110,20 +110,23 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1:
|
|||||||
- Eigenständiges Skript `src/mailer.py`, läuft im selben Container über
|
- Eigenständiges Skript `src/mailer.py`, läuft im selben Container über
|
||||||
einen zweiten Cron-Eintrag, täglich zur in `MAIL_SEND_HOUR`
|
einen zweiten Cron-Eintrag, täglich zur in `MAIL_SEND_HOUR`
|
||||||
konfigurierten Stunde (Default 7 Uhr).
|
konfigurierten Stunde (Default 7 Uhr).
|
||||||
- Query: alle Kontakte über alle Accounts hinweg, deren `birthday`
|
- Pro Account mit gesetztem `birthday_mail_to` wird eine eigene E-Mail
|
||||||
|
versendet, die nur Geburtstage aus diesem Account enthält.
|
||||||
|
- Query: Kontakte des jeweiligen Accounts, deren `birthday`
|
||||||
(Monat/Tag) auf das heutige Datum fällt.
|
(Monat/Tag) auf das heutige Datum fällt.
|
||||||
- Versand nur bei existierenden Geburtstagen: Wird keine E-Mail
|
- Versand nur bei existierenden Geburtstagen: Wird keine E-Mail
|
||||||
versendet, wenn die Abfrage keine Treffer liefert.
|
versendet, wenn die Abfrage keine Treffer liefert.
|
||||||
- Versand per SMTP mit STARTTLS (`smtplib`), Konfiguration über
|
- Versand per SMTP mit STARTTLS (`smtplib`), Konfiguration über
|
||||||
`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `MAIL_FROM`,
|
`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `MAIL_FROM`.
|
||||||
`MAIL_TO`.
|
Die Empfänger-Adresse (`birthday_mail_to`) wird pro Account in
|
||||||
|
`accounts.json` konfiguriert.
|
||||||
- Idempotenz: vor dem Versand wird `birthday_mail_log` auf einen
|
- Idempotenz: vor dem Versand wird `birthday_mail_log` auf einen
|
||||||
Eintrag für den heutigen Tag geprüft; existiert bereits einer, wird
|
Eintrag für den heutigen Tag und den jeweiligen Account geprüft;
|
||||||
der Lauf ohne erneuten Versand beendet.
|
existiert bereits einer, wird der Lauf ohne erneuten Versand beendet.
|
||||||
- Feature-Flag `MAILER_ENABLED` erlaubt das komplette Deaktivieren ohne
|
- Feature-Flag `MAILER_ENABLED` erlaubt das komplette Deaktivieren ohne
|
||||||
Codeänderung (Default: `false`).
|
Codeänderung (Default: `false`).
|
||||||
- E-Mail-Inhalt: HTML-E-Mail mit stylisierten Geburtstagskarten
|
- E-Mail-Inhalt: HTML-E-Mail mit stylisierten Geburtstagskarten
|
||||||
(Name, Alter, Account, Link zur Kontakt-Detailseite falls `WEB_URL`
|
(Name, Alter, Link zur Kontakt-Detailseite falls `WEB_URL`
|
||||||
gesetzt). Zusätzlich reiner Text-Alternative als Fallback.
|
gesetzt). Zusätzlich reiner Text-Alternative als Fallback.
|
||||||
|
|
||||||
## 7. Konfiguration (Umgebungsvariablen)
|
## 7. Konfiguration (Umgebungsvariablen)
|
||||||
@@ -144,13 +147,15 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1:
|
|||||||
| SMTP_PASSWORD | nein | leer, falls Relay ohne Auth |
|
| SMTP_PASSWORD | nein | leer, falls Relay ohne Auth |
|
||||||
| SMTP_USE_TLS | nein | Default: true |
|
| SMTP_USE_TLS | nein | Default: true |
|
||||||
| MAIL_FROM | ja (Mailer) | Absenderadresse |
|
| MAIL_FROM | ja (Mailer) | Absenderadresse |
|
||||||
| MAIL_TO | ja (Mailer) | Empfängeradresse(n) |
|
|
||||||
| MAIL_SEND_HOUR | nein | Default: 7, Stunde (0-23) für täglichen Mailversand |
|
| MAIL_SEND_HOUR | nein | Default: 7, Stunde (0-23) für täglichen Mailversand |
|
||||||
| WEB_URL | nein | Web-URL für Links in Geburtstags-Mails (z.B. https://kontakte.example.de) |
|
| WEB_URL | nein | Web-URL für Links in Geburtstags-Mails (z.B. https://kontakte.example.de) |
|
||||||
| 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 |
|
||||||
|
|
||||||
|
Empfänger-Adresse für Geburtstags-Mails: `birthday_mail_to` pro Account
|
||||||
|
in `accounts.json` (keine globale Umgebungsvariable mehr nötig).
|
||||||
|
|
||||||
Secrets werden weiterhin als klassische Umgebungsvariablen übergeben,
|
Secrets werden weiterhin als klassische Umgebungsvariablen übergeben,
|
||||||
mit Ausnahme der Multi-Account-Zugangsdaten, die aus `accounts.json`
|
mit Ausnahme der Multi-Account-Zugangsdaten, die aus `accounts.json`
|
||||||
gelesen werden (per Volume-Mount, nicht im Image, nicht im Git).
|
gelesen werden (per Volume-Mount, nicht im Image, nicht im Git).
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Felder pro Account:
|
|||||||
`Remote-User`-Header an die Web-Ansicht/API durchreicht. Dieser Wert
|
`Remote-User`-Header an die Web-Ansicht/API durchreicht. Dieser Wert
|
||||||
bestimmt, welchen Account ein eingeloggter Benutzer in der
|
bestimmt, welchen Account ein eingeloggter Benutzer in der
|
||||||
Web-Ansicht sieht.
|
Web-Ansicht sieht.
|
||||||
|
- `birthday_mail_to` (optional): Empfänger-Adresse für die tägliche
|
||||||
|
Geburtstagsmail. Fehlt das Feld oder ist leer, wird für diesen Account
|
||||||
|
keine Geburtstagsmail versendet.
|
||||||
- `healthcheck_url` (optional): URL, die nach jedem erfolgreichen
|
- `healthcheck_url` (optional): URL, die nach jedem erfolgreichen
|
||||||
Sync-Lauf dieses Accounts aufgerufen wird (z.B. für Uptime-Monitoring
|
Sync-Lauf dieses Accounts aufgerufen wird (z.B. für Uptime-Monitoring
|
||||||
wie Healthchecks.io). Bei Sync-Fehlern wird die URL nicht aufgerufen.
|
wie Healthchecks.io). Bei Sync-Fehlern wird die URL nicht aufgerufen.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"apple_email": "markus@icloud.com",
|
"apple_email": "markus@icloud.com",
|
||||||
"apple_app_password": "xxxx-xxxx-xxxx-xxxx",
|
"apple_app_password": "xxxx-xxxx-xxxx-xxxx",
|
||||||
"authelia_user": "mmustermann",
|
"authelia_user": "mmustermann",
|
||||||
|
"birthday_mail_to": "markus@example.de",
|
||||||
"custom_links": [
|
"custom_links": [
|
||||||
{"label": "Google Suche", "url": "https://www.google.com/search?q=[fullname]"},
|
{"label": "Google Suche", "url": "https://www.google.com/search?q=[fullname]"},
|
||||||
{"label": "LinkedIn", "url": "https://www.linkedin.com/search/results/all/?keywords=[fullname]"},
|
{"label": "LinkedIn", "url": "https://www.linkedin.com/search/results/all/?keywords=[fullname]"},
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"apple_email": "partner@icloud.com",
|
"apple_email": "partner@icloud.com",
|
||||||
"apple_app_password": "yyyy-yyyy-yyyy-yyyy",
|
"apple_app_password": "yyyy-yyyy-yyyy-yyyy",
|
||||||
"authelia_user": "partner.user",
|
"authelia_user": "partner.user",
|
||||||
|
"birthday_mail_to": "partner@example.de",
|
||||||
"custom_links": [],
|
"custom_links": [],
|
||||||
"healthcheck_url": ""
|
"healthcheck_url": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ services:
|
|||||||
SMTP_PASSWORD: "${SMTP_PASSWORD}"
|
SMTP_PASSWORD: "${SMTP_PASSWORD}"
|
||||||
SMTP_USE_TLS: "${SMTP_USE_TLS:-true}"
|
SMTP_USE_TLS: "${SMTP_USE_TLS:-true}"
|
||||||
MAIL_FROM: "${MAIL_FROM}"
|
MAIL_FROM: "${MAIL_FROM}"
|
||||||
MAIL_TO: "${MAIL_TO}"
|
|
||||||
MAIL_SEND_HOUR: "${MAIL_SEND_HOUR:-7}"
|
MAIL_SEND_HOUR: "${MAIL_SEND_HOUR:-7}"
|
||||||
TIMEZONE: "${TIMEZONE:-Europe/Berlin}"
|
TIMEZONE: "${TIMEZONE:-Europe/Berlin}"
|
||||||
WEB_URL: "${WEB_URL:-}"
|
WEB_URL: "${WEB_URL:-}"
|
||||||
@@ -57,7 +56,6 @@ services:
|
|||||||
SMTP_PASSWORD: "${SMTP_PASSWORD}"
|
SMTP_PASSWORD: "${SMTP_PASSWORD}"
|
||||||
SMTP_USE_TLS: "${SMTP_USE_TLS:-true}"
|
SMTP_USE_TLS: "${SMTP_USE_TLS:-true}"
|
||||||
MAIL_FROM: "${MAIL_FROM}"
|
MAIL_FROM: "${MAIL_FROM}"
|
||||||
MAIL_TO: "${MAIL_TO}"
|
|
||||||
TIMEZONE: "${TIMEZONE:-Europe/Berlin}"
|
TIMEZONE: "${TIMEZONE:-Europe/Berlin}"
|
||||||
WEB_URL: "${WEB_URL:-}"
|
WEB_URL: "${WEB_URL:-}"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+3
-2
@@ -81,11 +81,12 @@ CREATE TABLE IF NOT EXISTS group_members (
|
|||||||
KEY idx_group_members_member_uid (member_uid)
|
KEY idx_group_members_member_uid (member_uid)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Protokoll der Geburtstags-Mails, verhindert Doppelversand am selben Tag.
|
-- Protokoll der Geburtstags-Mails, verhindert Doppelversand am selben Tag pro Account.
|
||||||
CREATE TABLE IF NOT EXISTS birthday_mail_log (
|
CREATE TABLE IF NOT EXISTS birthday_mail_log (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
account VARCHAR(100) NOT NULL,
|
||||||
sent_date DATE NOT NULL,
|
sent_date DATE NOT NULL,
|
||||||
contacts_count INT NOT NULL,
|
contacts_count INT NOT NULL,
|
||||||
sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY uq_birthday_mail_date (sent_date)
|
UNIQUE KEY uq_birthday_mail_date_account (account, sent_date)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
+33
-10
@@ -29,7 +29,7 @@ from api.schemas import (
|
|||||||
SyncRunOut,
|
SyncRunOut,
|
||||||
)
|
)
|
||||||
from config import Config
|
from config import Config
|
||||||
from mailer import build_message, fetch_birthdays_for_date, send_message
|
from mailer import build_message, fetch_todays_birthdays_for_account, send_message
|
||||||
from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year
|
from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year
|
||||||
|
|
||||||
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
|
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
@@ -439,20 +439,43 @@ def admin_test_send(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
with db.get_connection() as conn:
|
accounts = Config.load_accounts()
|
||||||
birthdays = fetch_birthdays_for_date(conn, target)
|
mail_accounts = [a for a in accounts if a.birthday_mail_to]
|
||||||
|
if not mail_accounts:
|
||||||
msg = build_message(birthdays, target_date=target)
|
|
||||||
try:
|
|
||||||
send_message(msg)
|
|
||||||
except Exception as exc:
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"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: {exc}",
|
"error": "Keine Accounts mit birthday_mail_to konfiguriert",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
errors = []
|
||||||
|
with db.get_connection() as conn:
|
||||||
|
for account in mail_accounts:
|
||||||
|
birthdays = fetch_todays_birthdays_for_account(conn, account.name, target)
|
||||||
|
if not birthdays:
|
||||||
|
continue
|
||||||
|
msg = build_message(account.name, birthdays, target_date=target)
|
||||||
|
msg["To"] = account.birthday_mail_to
|
||||||
|
try:
|
||||||
|
send_message(msg)
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"{account.name}: {exc}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user,
|
||||||
|
"show_all": request.session.get("show_all", False),
|
||||||
|
"error": f"Versand fehlgeschlagen: {'; '.join(errors)}",
|
||||||
},
|
},
|
||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
@@ -463,7 +486,7 @@ def admin_test_send(
|
|||||||
"request": request,
|
"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-Mail für {target.strftime('%d.%m.%Y')} gesendet ({len(birthdays)} Kontakte).",
|
"success": f"Test-Mails für {target.strftime('%d.%m.%Y')} gesendet ({sent_count} Accounts).",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -230,7 +230,7 @@
|
|||||||
<button type="submit" class="btn">Test-Geburtstagsmail senden (06.08.)</button>
|
<button type="submit" class="btn">Test-Geburtstagsmail senden (06.08.)</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="test-info">
|
<div class="test-info">
|
||||||
Sendet eine Test-Geburtstagsmail an die konfigurierte Adresse (MAIL_TO) mit allen Kontakten, die am 6. August Geburtstag haben. Das Layout entspricht der täglichen Geburtstagsmail.
|
Sendet eine Test-Geburtstagsmail an die im Account hinterlegte Adresse (birthday_mail_to) mit allen Kontakten, die am 6. August Geburtstag haben. Das Layout entspricht der täglichen Geburtstagsmail.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-4
@@ -13,13 +13,15 @@ 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):
|
||||||
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
|
||||||
self.authelia_user = authelia_user
|
self.authelia_user = authelia_user
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -43,7 +45,6 @@ class Config:
|
|||||||
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
||||||
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
|
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
|
||||||
MAIL_FROM = os.environ.get("MAIL_FROM", "")
|
MAIL_FROM = os.environ.get("MAIL_FROM", "")
|
||||||
MAIL_TO = os.environ.get("MAIL_TO", "")
|
|
||||||
|
|
||||||
# Authelia liefert den eingeloggten Benutzer per Header, der vom
|
# Authelia liefert den eingeloggten Benutzer per Header, der vom
|
||||||
# vorgeschalteten nginx/traefik als Remote-User weitergereicht wird.
|
# vorgeschalteten nginx/traefik als Remote-User weitergereicht wird.
|
||||||
@@ -67,7 +68,6 @@ class Config:
|
|||||||
missing = [n for n, v in [
|
missing = [n for n, v in [
|
||||||
("SMTP_HOST", cls.SMTP_HOST),
|
("SMTP_HOST", cls.SMTP_HOST),
|
||||||
("MAIL_FROM", cls.MAIL_FROM),
|
("MAIL_FROM", cls.MAIL_FROM),
|
||||||
("MAIL_TO", cls.MAIL_TO),
|
|
||||||
] if not v]
|
] if not v]
|
||||||
if missing:
|
if missing:
|
||||||
raise RuntimeError(f"Fehlende Mailer-Umgebungsvariablen: {', '.join(missing)}")
|
raise RuntimeError(f"Fehlende Mailer-Umgebungsvariablen: {', '.join(missing)}")
|
||||||
@@ -103,7 +103,8 @@ class Config:
|
|||||||
seen_names.add(name)
|
seen_names.add(name)
|
||||||
custom_links = entry.get("custom_links", [])
|
custom_links = entry.get("custom_links", [])
|
||||||
healthcheck_url = entry.get("healthcheck_url", "")
|
healthcheck_url = entry.get("healthcheck_url", "")
|
||||||
accounts.append(Account(name, email, pwd, authelia_user, custom_links, healthcheck_url))
|
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))
|
||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+50
-35
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
# Licensed under the MIT License. See LICENSE file in project root for details.
|
# Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
"""Sendet eine tägliche E-Mail mit allen heutigen Geburtstagskindern aus der
|
"""Sendet eine tägliche E-Mail mit Geburtstagskindern pro Account aus der
|
||||||
contacts-Tabelle (über alle Accounts hinweg). Wird per Cron einmal täglich
|
contacts-Tabelle. Wird per Cron einmal täglich um MAIL_SEND_HOUR aufgerufen.
|
||||||
um MAIL_SEND_HOUR aufgerufen. Verhindert Doppelversand am selben Tag über
|
Verhindert Doppelversand am selben Tag pro Account über die Tabelle
|
||||||
die Tabelle birthday_mail_log."""
|
birthday_mail_log."""
|
||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
import sys
|
import sys
|
||||||
@@ -19,17 +19,18 @@ logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s]
|
|||||||
logger = logging.getLogger("mailer")
|
logger = logging.getLogger("mailer")
|
||||||
|
|
||||||
|
|
||||||
def fetch_birthdays_for_date(conn, target_date: date) -> list[dict]:
|
def fetch_todays_birthdays_for_account(conn, account_name: str, target_date: date) -> list[dict]:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT id, account, full_name, given_name, middle_name, family_name,
|
"""SELECT id, account, full_name, given_name, middle_name, family_name,
|
||||||
prefix, suffix, birthday, organization, photo_url
|
prefix, suffix, birthday, organization, photo_url
|
||||||
FROM contacts
|
FROM contacts
|
||||||
WHERE birthday IS NOT NULL
|
WHERE account = %s
|
||||||
|
AND birthday IS NOT NULL
|
||||||
AND MONTH(birthday) = %s
|
AND MONTH(birthday) = %s
|
||||||
AND DAY(birthday) = %s
|
AND DAY(birthday) = %s
|
||||||
ORDER BY given_name, family_name, full_name""",
|
ORDER BY given_name, family_name, full_name""",
|
||||||
(target_date.month, target_date.day),
|
(account_name, target_date.month, target_date.day),
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
for b in rows:
|
for b in rows:
|
||||||
@@ -38,32 +39,30 @@ def fetch_birthdays_for_date(conn, target_date: date) -> list[dict]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def fetch_todays_birthdays(conn) -> list[dict]:
|
def already_sent_today(conn, account: str) -> bool:
|
||||||
return fetch_birthdays_for_date(conn, datetime.now(Config.TIMEZONE).date())
|
|
||||||
|
|
||||||
|
|
||||||
def already_sent_today(conn) -> bool:
|
|
||||||
today = datetime.now(Config.TIMEZONE).date()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("SELECT 1 FROM birthday_mail_log WHERE sent_date = %s", (today,))
|
|
||||||
return cur.fetchone() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def log_sent(conn, count: int):
|
|
||||||
today = datetime.now(Config.TIMEZONE).date()
|
today = datetime.now(Config.TIMEZONE).date()
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO birthday_mail_log (sent_date, contacts_count) VALUES (%s, %s)",
|
"SELECT 1 FROM birthday_mail_log WHERE account = %s AND sent_date = %s",
|
||||||
(today, count),
|
(account, today),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def log_sent(conn, account: str, count: int):
|
||||||
|
today = datetime.now(Config.TIMEZONE).date()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO birthday_mail_log (account, sent_date, contacts_count) VALUES (%s, %s, %s)",
|
||||||
|
(account, today, count),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def build_message(birthdays: list[dict], target_date: date | None = None) -> EmailMessage:
|
def build_message(account_name: str, birthdays: list[dict], target_date: date | None = None) -> EmailMessage:
|
||||||
today = target_date or datetime.now(Config.TIMEZONE).date()
|
today = target_date or datetime.now(Config.TIMEZONE).date()
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg["From"] = Config.MAIL_FROM
|
msg["From"] = Config.MAIL_FROM
|
||||||
msg["To"] = Config.MAIL_TO
|
|
||||||
|
|
||||||
if not birthdays:
|
if not birthdays:
|
||||||
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): keine"
|
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): keine"
|
||||||
@@ -199,26 +198,42 @@ def main() -> int:
|
|||||||
logger.error(str(exc))
|
logger.error(str(exc))
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
accounts = Config.load_accounts()
|
||||||
|
mail_accounts = [a for a in accounts if a.birthday_mail_to]
|
||||||
|
if not mail_accounts:
|
||||||
|
logger.info("Keine Accounts mit birthday_mail_to konfiguriert, überspringe Lauf")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
today = datetime.now(Config.TIMEZONE).date()
|
||||||
|
sent_count = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
with db.get_connection() as conn:
|
with db.get_connection() as conn:
|
||||||
if already_sent_today(conn):
|
for account in mail_accounts:
|
||||||
logger.info("Geburtstagsmail wurde heute bereits versendet, überspringe")
|
if already_sent_today(conn, account.name):
|
||||||
return 0
|
logger.info("Geburtstagsmail für Account '%s' wurde heute bereits versendet, überspringe", account.name)
|
||||||
|
continue
|
||||||
|
|
||||||
birthdays = fetch_todays_birthdays(conn)
|
birthdays = fetch_todays_birthdays_for_account(conn, account.name, today)
|
||||||
if not birthdays:
|
if not birthdays:
|
||||||
logger.info("Keine Geburtstage heute, überspringe Mailversand")
|
logger.info("Keine Geburtstage heute für Account '%s', überspringe", account.name)
|
||||||
return 0
|
continue
|
||||||
|
|
||||||
msg = build_message(birthdays)
|
msg = build_message(account.name, birthdays, today)
|
||||||
|
msg["To"] = account.birthday_mail_to
|
||||||
try:
|
try:
|
||||||
send_message(msg)
|
send_message(msg)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Versand der Geburtstagsmail fehlgeschlagen")
|
logger.exception("Versand der Geburtstagsmail für Account '%s' fehlgeschlagen", account.name)
|
||||||
return 1
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
log_sent(conn, len(birthdays))
|
log_sent(conn, account.name, len(birthdays))
|
||||||
logger.info("Geburtstagsmail versendet: %d Kontakte", len(birthdays))
|
sent_count += 1
|
||||||
return 0
|
logger.info("Geburtstagsmail versendet für Account '%s': %d Kontakte", account.name, len(birthdays))
|
||||||
|
|
||||||
|
logger.info("Mailer-Lauf abgeschlossen: %d Mails versendet, %d Fehler", sent_count, errors)
|
||||||
|
return 1 if errors > 0 else 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user