mirror of
https://github.com/skoelle/calender_sync.git
synced 2026-09-17 18:20:24 +00:00
new optional notify feature
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user