new optional notify feature

This commit is contained in:
2026-08-04 19:28:44 +02:00
parent 5343896653
commit 3b05342409
5 changed files with 238 additions and 1 deletions
+11
View File
@@ -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
+8
View File
@@ -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
+32
View File
@@ -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.
+10
View File
@@ -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
+177 -1
View File
@@ -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"""
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; font-weight: bold; white-space: nowrap;">{time_str}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee;">{summary}{location}</td>
</tr>"""
html = f"""
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">Guten Morgen!</h2>
<p style="color: #555;">Heute, <strong>{date_str}</strong>, stehen folgende Termine an:</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; background: #f9f9f9; border-radius: 8px; overflow: hidden;">
{event_rows}
</table>
<p style="color: #888; font-size: 12px;">Viel Erfolg heute!</p>
</body>
</html>
"""
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)