From 5e1a2b0b9273100c8afad0f840b52a2496a353b5 Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Fri, 7 Aug 2026 21:31:53 +0200 Subject: [PATCH] birthday mail for each account --- .env.example | 1 - README.md | 24 ++++++---- SPEC.md | 27 ++++++----- config/README.md | 3 ++ config/accounts.json.example | 2 + docker-compose.yml | 2 - sql/schema.sql | 5 +- src/api/main.py | 43 +++++++++++++---- src/api/templates/admin.html | 2 +- src/config.py | 9 ++-- src/mailer.py | 93 +++++++++++++++++++++--------------- 11 files changed, 131 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index b60902a..73d7430 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,6 @@ SMTP_USER=mailer@example.de SMTP_PASSWORD=change-me SMTP_USE_TLS=true MAIL_FROM=contacts-sync@example.de -MAIL_TO=du@example.de MAIL_SEND_HOUR=7 # --- Web-Ansicht / API (nur relevant für den zweiten Container) --- diff --git a/README.md b/README.md index b334aa1..0c93246 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,10 @@ vim config/accounts.json ``` Trage für jede Apple-ID einen Eintrag mit eindeutigem `name`, -`apple_email`, `apple_app_password` und `authelia_user` ein. Optional -kann pro Account eine `healthcheck_url` konfiguriert werden, die nach -jedem erfolgreichen Sync aufgerufen wird (z.B. für Uptime-Monitoring). -Diese Datei +`apple_email`, `apple_app_password`, `authelia_user` und (optional) +`birthday_mail_to` ein. Optional kann pro Account eine `healthcheck_url` +konfiguriert werden, die nach jedem erfolgreichen Sync aufgerufen wird +(z.B. für Uptime-Monitoring). Diese Datei bleibt lokal auf dem Host, sie ist in `.gitignore` ausgeschlossen und wird nur als Volume in den Container gemountet. @@ -58,7 +58,9 @@ vim .env ``` 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 @@ -106,7 +108,7 @@ SELECT account, sync_token, updated_at FROM sync_state; Versandhistorie der Geburtstagsmails: ```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; ``` @@ -114,16 +116,18 @@ ORDER BY sent_date DESC LIMIT 10; - Läuft automatisch täglich um die in `MAIL_SEND_HOUR` konfigurierte Stunde (Default 7 Uhr) innerhalb desselben Containers. -- Versendet eine HTML-E-Mail mit stylisierten Geburtstagskarten und - Links zur Kontakt-Detailseite (falls `WEB_URL` gesetzt). +- Versendet pro Account mit gesetztem `birthday_mail_to` eine eigene + 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, ohne den Kontakt-Sync zu beeinträchtigen. - Manueller Testlauf im laufenden Container: ``` docker exec -it icloud-contacts-sync python3 /app/mailer.py ``` -- Ein zweiter manueller Lauf am selben Tag versendet keine zweite Mail, - solange bereits ein Eintrag in `birthday_mail_log` für heute existiert. +- Ein zweiter manueller Lauf am selben Tag versendet keine zweite Mail + pro Account, solange bereits ein Eintrag in `birthday_mail_log` für + heute und diesen Account existiert. ## 9. Lokale Entwicklung (ohne Docker) diff --git a/SPEC.md b/SPEC.md index cd090c1..8f234f4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -39,8 +39,8 @@ außerhalb des Apple-Ökosystems. ```json { "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": "partner", "apple_email": "partner@icloud.com", "apple_app_password": "yyyy-yyyy-yyyy-yyyy", "authelia_user": "pmustermann" } + { "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", "birthday_mail_to": "partner@example.de" } ], "admins": ["mmustermann"] } @@ -96,8 +96,8 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1: aktuellen `sync_token`. - `sync_runs` erweitert um `account`, `sync_type` (`initial`/`delta`), `contacts_upserted`, `contacts_deleted`. -- Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag, an dem - erfolgreich eine Geburtstagsmail versendet wurde, verhindert +- Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag und Account, + an dem erfolgreich eine Geburtstagsmail versendet wurde, verhindert Doppelversand bei mehrfachem Container-Neustart am selben Tag. - Neue Tabellen `groups` und `group_members`: Speichert 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 einen zweiten Cron-Eintrag, täglich zur in `MAIL_SEND_HOUR` konfigurierten Stunde (Default 7 Uhr). -- Query: alle Kontakte über alle Accounts hinweg, deren `birthday` +- 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. - Versand nur bei existierenden Geburtstagen: Wird keine E-Mail versendet, wenn die Abfrage keine Treffer liefert. - Versand per SMTP mit STARTTLS (`smtplib`), Konfiguration über - `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `MAIL_FROM`, - `MAIL_TO`. + `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `MAIL_FROM`. + Die Empfänger-Adresse (`birthday_mail_to`) wird pro Account in + `accounts.json` konfiguriert. - Idempotenz: vor dem Versand wird `birthday_mail_log` auf einen - Eintrag für den heutigen Tag geprüft; existiert bereits einer, wird - der Lauf ohne erneuten Versand beendet. + Eintrag für den heutigen Tag und den jeweiligen Account geprüft; + existiert bereits einer, wird der Lauf ohne erneuten Versand beendet. - Feature-Flag `MAILER_ENABLED` erlaubt das komplette Deaktivieren ohne Codeänderung (Default: `false`). - 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. ## 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_USE_TLS | nein | Default: true | | MAIL_FROM | ja (Mailer) | Absenderadresse | -| MAIL_TO | ja (Mailer) | Empfängeradresse(n) | | MAIL_SEND_HOUR | nein | Default: 7, Stunde (0-23) für täglichen Mailversand | | 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 | | API_HOST | nein | Default: 0.0.0.0, Bindungs-Adresse 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, mit Ausnahme der Multi-Account-Zugangsdaten, die aus `accounts.json` gelesen werden (per Volume-Mount, nicht im Image, nicht im Git). diff --git a/config/README.md b/config/README.md index cebddf7..bfbd7c1 100644 --- a/config/README.md +++ b/config/README.md @@ -15,6 +15,9 @@ Felder pro Account: `Remote-User`-Header an die Web-Ansicht/API durchreicht. Dieser Wert bestimmt, welchen Account ein eingeloggter Benutzer in der 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 Sync-Lauf dieses Accounts aufgerufen wird (z.B. für Uptime-Monitoring wie Healthchecks.io). Bei Sync-Fehlern wird die URL nicht aufgerufen. diff --git a/config/accounts.json.example b/config/accounts.json.example index 1d663ff..d0a4b44 100644 --- a/config/accounts.json.example +++ b/config/accounts.json.example @@ -5,6 +5,7 @@ "apple_email": "markus@icloud.com", "apple_app_password": "xxxx-xxxx-xxxx-xxxx", "authelia_user": "mmustermann", + "birthday_mail_to": "markus@example.de", "custom_links": [ {"label": "Google Suche", "url": "https://www.google.com/search?q=[fullname]"}, {"label": "LinkedIn", "url": "https://www.linkedin.com/search/results/all/?keywords=[fullname]"}, @@ -18,6 +19,7 @@ "apple_email": "partner@icloud.com", "apple_app_password": "yyyy-yyyy-yyyy-yyyy", "authelia_user": "partner.user", + "birthday_mail_to": "partner@example.de", "custom_links": [], "healthcheck_url": "" } diff --git a/docker-compose.yml b/docker-compose.yml index b9fa7f5..12c47c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,6 @@ services: SMTP_PASSWORD: "${SMTP_PASSWORD}" SMTP_USE_TLS: "${SMTP_USE_TLS:-true}" MAIL_FROM: "${MAIL_FROM}" - MAIL_TO: "${MAIL_TO}" MAIL_SEND_HOUR: "${MAIL_SEND_HOUR:-7}" TIMEZONE: "${TIMEZONE:-Europe/Berlin}" WEB_URL: "${WEB_URL:-}" @@ -57,7 +56,6 @@ services: SMTP_PASSWORD: "${SMTP_PASSWORD}" SMTP_USE_TLS: "${SMTP_USE_TLS:-true}" MAIL_FROM: "${MAIL_FROM}" - MAIL_TO: "${MAIL_TO}" TIMEZONE: "${TIMEZONE:-Europe/Berlin}" WEB_URL: "${WEB_URL:-}" volumes: diff --git a/sql/schema.sql b/sql/schema.sql index 2a1ffac..9ef6be5 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -81,11 +81,12 @@ CREATE TABLE IF NOT EXISTS group_members ( KEY idx_group_members_member_uid (member_uid) ) 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 ( id INT AUTO_INCREMENT PRIMARY KEY, + account VARCHAR(100) NOT NULL, sent_date DATE NOT NULL, contacts_count INT NOT NULL, sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uq_birthday_mail_date (sent_date) + UNIQUE KEY uq_birthday_mail_date_account (account, sent_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/api/main.py b/src/api/main.py index baa5438..0dfea57 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -29,7 +29,7 @@ from api.schemas import ( SyncRunOut, ) 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 logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") @@ -439,20 +439,43 @@ def admin_test_send( status_code=400, ) - with db.get_connection() as conn: - birthdays = fetch_birthdays_for_date(conn, target) - - msg = build_message(birthdays, target_date=target) - try: - send_message(msg) - except Exception as exc: + accounts = Config.load_accounts() + mail_accounts = [a for a in accounts if a.birthday_mail_to] + if not mail_accounts: return templates.TemplateResponse( "admin.html", { "request": request, "current_user": current_user, "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, ) @@ -463,7 +486,7 @@ def admin_test_send( "request": request, "current_user": current_user, "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).", }, ) diff --git a/src/api/templates/admin.html b/src/api/templates/admin.html index a687b3c..cb262ce 100644 --- a/src/api/templates/admin.html +++ b/src/api/templates/admin.html @@ -230,7 +230,7 @@
- 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.
diff --git a/src/config.py b/src/config.py index 53f238b..67bf83d 100644 --- a/src/config.py +++ b/src/config.py @@ -13,13 +13,15 @@ ICLOUD_BASE_URL = "https://contacts.icloud.com/" class Account: def __init__(self, name: str, apple_email: str, apple_app_password: str, authelia_user: str | None, - 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.apple_email = apple_email self.apple_app_password = apple_app_password self.authelia_user = authelia_user self.custom_links = custom_links or [] self.healthcheck_url = healthcheck_url + self.birthday_mail_to = birthday_mail_to class Config: @@ -43,7 +45,6 @@ class Config: SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true" MAIL_FROM = os.environ.get("MAIL_FROM", "") - MAIL_TO = os.environ.get("MAIL_TO", "") # Authelia liefert den eingeloggten Benutzer per Header, der vom # vorgeschalteten nginx/traefik als Remote-User weitergereicht wird. @@ -67,7 +68,6 @@ class Config: missing = [n for n, v in [ ("SMTP_HOST", cls.SMTP_HOST), ("MAIL_FROM", cls.MAIL_FROM), - ("MAIL_TO", cls.MAIL_TO), ] if not v] if missing: raise RuntimeError(f"Fehlende Mailer-Umgebungsvariablen: {', '.join(missing)}") @@ -103,7 +103,8 @@ class Config: seen_names.add(name) custom_links = entry.get("custom_links", []) 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 @classmethod diff --git a/src/mailer.py b/src/mailer.py index 2c46940..4f800a8 100644 --- a/src/mailer.py +++ b/src/mailer.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 # Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) # 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 -contacts-Tabelle (über alle Accounts hinweg). Wird per Cron einmal täglich -um MAIL_SEND_HOUR aufgerufen. Verhindert Doppelversand am selben Tag über -die Tabelle birthday_mail_log.""" +"""Sendet eine tägliche E-Mail mit Geburtstagskindern pro Account aus der +contacts-Tabelle. Wird per Cron einmal täglich um MAIL_SEND_HOUR aufgerufen. +Verhindert Doppelversand am selben Tag pro Account über die Tabelle +birthday_mail_log.""" import logging import smtplib import sys @@ -19,17 +19,18 @@ logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] 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: cur.execute( """SELECT id, account, full_name, given_name, middle_name, family_name, prefix, suffix, birthday, organization, photo_url FROM contacts - WHERE birthday IS NOT NULL + WHERE account = %s + AND birthday IS NOT NULL AND MONTH(birthday) = %s AND DAY(birthday) = %s 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() for b in rows: @@ -38,32 +39,30 @@ def fetch_birthdays_for_date(conn, target_date: date) -> list[dict]: return rows -def fetch_todays_birthdays(conn) -> list[dict]: - 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): +def already_sent_today(conn, account: str) -> bool: today = datetime.now(Config.TIMEZONE).date() with conn.cursor() as cur: cur.execute( - "INSERT INTO birthday_mail_log (sent_date, contacts_count) VALUES (%s, %s)", - (today, count), + "SELECT 1 FROM birthday_mail_log WHERE account = %s AND sent_date = %s", + (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() -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() msg = EmailMessage() msg["From"] = Config.MAIL_FROM - msg["To"] = Config.MAIL_TO if not birthdays: msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): keine" @@ -199,26 +198,42 @@ def main() -> int: logger.error(str(exc)) 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: - if already_sent_today(conn): - logger.info("Geburtstagsmail wurde heute bereits versendet, überspringe") - return 0 + for account in mail_accounts: + if already_sent_today(conn, account.name): + logger.info("Geburtstagsmail für Account '%s' wurde heute bereits versendet, überspringe", account.name) + continue - birthdays = fetch_todays_birthdays(conn) - if not birthdays: - logger.info("Keine Geburtstage heute, überspringe Mailversand") - return 0 + birthdays = fetch_todays_birthdays_for_account(conn, account.name, today) + if not birthdays: + logger.info("Keine Geburtstage heute für Account '%s', überspringe", account.name) + continue - msg = build_message(birthdays) - try: - send_message(msg) - except Exception: - logger.exception("Versand der Geburtstagsmail fehlgeschlagen") - return 1 + msg = build_message(account.name, birthdays, today) + msg["To"] = account.birthday_mail_to + try: + send_message(msg) + except Exception: + logger.exception("Versand der Geburtstagsmail für Account '%s' fehlgeschlagen", account.name) + errors += 1 + continue - log_sent(conn, len(birthdays)) - logger.info("Geburtstagsmail versendet: %d Kontakte", len(birthdays)) - return 0 + log_sent(conn, account.name, len(birthdays)) + sent_count += 1 + 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__":