From 3b0534240933a476174509d0cbd44489d72ac022 Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Tue, 4 Aug 2026 13:48:07 +0200 Subject: [PATCH] new optional notify feature --- .env.example | 11 +++ AGENTS.md | 8 ++ README.md | 32 ++++++++ docker-compose.yml | 10 +++ sync.py | 178 ++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 238 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 0e02f8c..abcb6de 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,14 @@ API_PORT=8000 # Zeitzone für die API/Web-UI (Default: UTC) # TIMEZONE=Europe/Berlin + +# Optional: Tägliche E-Mail Benachrichtigung um 6 Uhr +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=user@example.com +# SMTP_PASSWORD=dein_passwort +# SMTP_FROM=user@example.com +# SMTP_USE_TLS=true +# NOTIFY_EMAIL=empfaenger@example.com +# NOTIFY_TIME=6 +# NOTIFY_TIMEZONE=Europe/Berlin diff --git a/AGENTS.md b/AGENTS.md index 80c708d..701790a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,14 @@ Alle Zeiten werden in naive UTC datetime konvertiert (`to_naive_utc()`). Bei Dat ### Soft-Delete Events werden nicht gelöscht, sondern mit `deleted=1` markiert (`mark_missing_as_deleted()`). +### Tägliche E-Mail-Benachrichtigung +Sendet täglich um konfigurierte Uhrzeit eine HTML-Email mit anstehenden Terminen: +- Nur Termine mit Uhrzeit (`all_day=0`), keine Ganztagstermine +- Subject: Bei 1 Termin direkt "Kalender heute: HH:MM - Termin", bei mehreren "Kalender heute: X Termine" +- Tracking via `daily_notification_log` Tabelle (verhindert Doppelversand) +- Konfiguration über SMTP_* und NOTIFY_* Umgebungsvariablen +- Funktionen: `get_today_events()`, `should_notify()`, `send_notification()`, `log_notification()` + ## Entwicklung ### Lokaler Test diff --git a/README.md b/README.md index 127a1f9..fa68445 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS - Zeitfenster-konfiguration für Vergangenheit/Future (standardmäßig -90 Tage / +365 Tage) - Web-UI zur Anzeige anstehender Termine mit Suchfunktion - REST API für programmsprachigen Zugriff auf Kalenderdaten +- Optionale tägliche E-Mail-Benachrichtigung um konfigurierte Uhrzeit ## Voraussetzungen @@ -73,6 +74,28 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS | `API_PORT` | `8000` | Port für den API/Web-UI Container | | `TIMEZONE` | `UTC` | Zeitzone für API/Web-UI Anzeige (z.B. `Europe/Berlin`) | +### Optionale E-Mail-Benachrichtigung + +Sendet täglich eine HTML-Email mit den anstehenden Terminen. Wird aktiviert wenn `SMTP_HOST` und `NOTIFY_EMAIL` gesetzt sind. + +| Variable | Default | Beschreibung | +|----------|---------|--------------| +| `SMTP_HOST` | - | SMTP Server Hostname | +| `SMTP_PORT` | `587` | SMTP Server Port | +| `SMTP_USER` | - | SMTP Login Username | +| `SMTP_PASSWORD` | - | SMTP Login Passwort | +| `SMTP_FROM` | - | Absender E-Mail Adresse | +| `SMTP_USE_TLS` | `true` | TLS verschlüsselung nutzen | +| `NOTIFY_EMAIL` | - | Empfänger E-Mail Adresse | +| `NOTIFY_TIME` | `6` | Uhrzeit für Benachrichtigung (Stunde, 0-23) | +| `NOTIFY_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Benachrichtigung | + +**Subject-Logik:** +- 1 Termin: `Kalender heute: 09:00 - Meeting mit Team` +- 2+ Termine: `Kalender heute: 3 Termine` + +**Hinweis:** Ganztagstermine werden nicht in der Benachrichtigung berücksichtigt. + ## Datenbank-Schema Tabelle `calendar_events`: @@ -96,6 +119,15 @@ Tabelle `calendar_events`: | `created_at` | DATETIME | Erstellungszeitpunkt | | `updated_at` | DATETIME | Letzte Änderung | +Tabelle `daily_notification_log` (optional, für E-Mail-Benachrichtigung): + +| Spalte | Typ | Beschreibung | +|--------|-----|--------------| +| `id` | INT PK | Auto-Increment | +| `notify_date` | DATE | Datum der Benachrichtigung | +| `sent_at` | DATETIME | Zeitpunkt des Versands | +| `event_count` | INT | Anzahl Termine in der Email | + ## Web-UI & API Das Projekt enthält eine FastAPI-basierte Webanwendung die als separater Container (`calendar-api`) läuft und auf Port `8000` erreichbar ist. diff --git a/docker-compose.yml b/docker-compose.yml index 6277222..2f3f758 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,16 @@ services: - DB_ROOT_USER=${DB_ROOT_USER:-} - DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-} + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM=${SMTP_FROM:-} + - SMTP_USE_TLS=${SMTP_USE_TLS:-true} + - NOTIFY_EMAIL=${NOTIFY_EMAIL:-} + - NOTIFY_TIME=${NOTIFY_TIME:-6} + - NOTIFY_TIMEZONE=${NOTIFY_TIMEZONE:-Europe/Berlin} + networks: - docker-backend diff --git a/sync.py b/sync.py index 2fda7c9..8df1951 100644 --- a/sync.py +++ b/sync.py @@ -10,7 +10,11 @@ import sys import time import logging import hashlib +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo import requests import icalendar @@ -45,6 +49,16 @@ DB_BOOTSTRAP = os.environ.get("DB_BOOTSTRAP", "false").lower() == "true" DB_ROOT_USER = os.environ.get("DB_ROOT_USER") DB_ROOT_PASSWORD = os.environ.get("DB_ROOT_PASSWORD") +SMTP_HOST = os.environ.get("SMTP_HOST", "") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) +SMTP_USER = os.environ.get("SMTP_USER", "") +SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") +SMTP_FROM = os.environ.get("SMTP_FROM", "") +SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true" +NOTIFY_EMAIL = os.environ.get("NOTIFY_EMAIL", "") +NOTIFY_TIME = int(os.environ.get("NOTIFY_TIME", "6")) +NOTIFY_TIMEZONE = os.environ.get("NOTIFY_TIMEZONE", "Europe/Berlin") + def bootstrap_database(): """Legt DB_NAME und DB_USER an, falls sie noch nicht existieren. @@ -106,6 +120,15 @@ def ensure_schema(conn): INDEX idx_deleted (deleted) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS daily_notification_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + notify_date DATE NOT NULL, + sent_at DATETIME NOT NULL, + event_count INT NOT NULL, + UNIQUE KEY uk_date (notify_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """) conn.commit() cur.close() @@ -198,6 +221,137 @@ def mark_missing_as_deleted(cur, calendar_label, run_ts, window_start, window_en return cur.rowcount +def get_today_events(conn, calendar_label): + tz = ZoneInfo(NOTIFY_TIMEZONE) + now = datetime.now(tz) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_end = today_start + timedelta(days=1) + + today_start_naive = today_start.replace(tzinfo=None) + today_end_naive = today_end.replace(tzinfo=None) + + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT summary, start_at, end_at, location + FROM calendar_events + WHERE calendar_label = %s + AND deleted = 0 + AND all_day = 0 + AND start_at >= %s + AND start_at < %s + ORDER BY start_at ASC + """, + (calendar_label, today_start_naive, today_end_naive), + ) + events = cur.fetchall() + cur.close() + return events + + +def should_notify(conn): + tz = ZoneInfo(NOTIFY_TIMEZONE) + now = datetime.now(tz) + + if now.hour != NOTIFY_TIME: + return False + + cur = conn.cursor() + cur.execute( + "SELECT COUNT(*) FROM daily_notification_log WHERE notify_date = CURDATE()" + ) + count = cur.fetchone()[0] + cur.close() + return count == 0 + + +def log_notification(conn, event_count): + cur = conn.cursor() + cur.execute( + "INSERT INTO daily_notification_log (notify_date, sent_at, event_count) VALUES (CURDATE(), NOW(), %s)", + (event_count,), + ) + conn.commit() + cur.close() + + +def format_event_time(start_at, end_at): + tz = ZoneInfo(NOTIFY_TIMEZONE) + start_local = start_at.replace(tzinfo=timezone.utc).astimezone(tz) + start_str = start_local.strftime("%H:%M") + if end_at: + end_local = end_at.replace(tzinfo=timezone.utc).astimezone(tz) + end_str = end_local.strftime("%H:%M") + return f"{start_str} - {end_str}" + return start_str + + +def send_notification(events): + if not SMTP_HOST or not NOTIFY_EMAIL: + log.warning("SMTP-Konfiguration unvollstaendig, ueberspringe Benachrichtigung") + return + + tz = ZoneInfo(NOTIFY_TIMEZONE) + now = datetime.now(tz) + date_str = now.strftime("%d.%m.%Y") + count = len(events) + + if count == 1: + event = events[0] + time_str = format_event_time(event["start_at"], event.get("end_at")) + subject = f"Kalender heute: {time_str} - {event['summary']}" + else: + subject = f"Kalender heute: {count} Termine" + + event_rows = "" + for event in events: + time_str = format_event_time(event["start_at"], event.get("end_at")) + summary = event["summary"] + location = f" ({event['location']})" if event.get("location") else "" + event_rows += f""" + + {time_str} + {summary}{location} + """ + + html = f""" + + + + +

Guten Morgen!

+

Heute, {date_str}, stehen folgende Termine an:

+ + {event_rows} +
+

Viel Erfolg heute!

+ + + """ + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = SMTP_FROM + msg["To"] = NOTIFY_EMAIL + msg.attach(MIMEText(html, "html", "utf-8")) + + try: + if SMTP_USE_TLS: + server = smtplib.SMTP(SMTP_HOST, SMTP_PORT) + server.starttls() + else: + server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) + + if SMTP_USER and SMTP_PASSWORD: + server.login(SMTP_USER, SMTP_PASSWORD) + + server.sendmail(SMTP_FROM, [NOTIFY_EMAIL], msg.as_string()) + server.quit() + log.info("Benachrichtigung gesendet: %s", subject) + except Exception: + log.exception("Fehler beim Senden der Benachrichtigung") + + def ping_healthcheck(): if not HEALTHCHECK_URL: return @@ -238,15 +392,37 @@ def run_sync_once(): conn.close() +def check_and_send_notification(): + if not SMTP_HOST or not NOTIFY_EMAIL: + return + + conn = get_connection(autocommit=True) + try: + if should_notify(conn): + events = get_today_events(conn, CALENDAR_LABEL) + if events: + send_notification(events) + log_notification(conn, len(events)) + log.info("Tagesbenachrichtigung fuer %d Events gesendet", len(events)) + else: + log.info("Keine Termine heute, Benachrichtigung wird uebersprungen") + except Exception: + log.exception("Fehler bei der Tagesbenachrichtigung") + finally: + conn.close() + + def main(): bootstrap_database() log.info( - "calendar-sync gestartet | Intervall=%smin | Fenster=-%dd/+%dd", + "calendar-sync gestartet | Intervall=%smin | Fenster=-%dd/+%dd | Benachrichtigung um %s:00 %s", SYNC_INTERVAL_MINUTES, WINDOW_PAST_DAYS, WINDOW_FUTURE_DAYS, + NOTIFY_TIME, NOTIFY_TIMEZONE, ) while True: try: run_sync_once() + check_and_send_notification() except Exception: log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut") time.sleep(SYNC_INTERVAL_MINUTES * 60)