From 0f16aa45dc2cd1fcd3b8726709bf90380de33106 Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Tue, 11 Aug 2026 22:04:11 +0200 Subject: [PATCH] mailer weekly notifier --- .env.example | 8 ++ AGENTS.md | 11 +++ README.md | 37 +++++++++ docker-compose.yml | 7 ++ sync.py | 190 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+) diff --git a/.env.example b/.env.example index abcb6de..b17a7ae 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,11 @@ API_PORT=8000 # NOTIFY_EMAIL=empfaenger@example.com # NOTIFY_TIME=6 # NOTIFY_TIMEZONE=Europe/Berlin + +# Optional: Wöchentliche Vorab-Info (jeden Freitag) +# WEEKLY_NOTIFY_ENABLED=true +# WEEKLY_NOTIFY_DAY=5 +# WEEKLY_NOTIFY_TIME=16 +# WEEKLY_NOTIFY_TIMEZONE=Europe/Berlin +# WEEKLY_NOTIFY_EMAIL=empfaenger@example.com +# WEEKLY_SEARCHWORDS=Termin1,Termin2 diff --git a/AGENTS.md b/AGENTS.md index 608bac5..6b8e012 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,17 @@ Sendet täglich um konfigurierte Uhrzeit eine HTML-Email mit anstehenden Termine - Konfiguration über SMTP_* und NOTIFY_* Umgebungsvariablen - Funktionen: `get_today_events()`, `should_notify()`, `send_notification()`, `log_notification()` +### Wöchentliche E-Mail-Benachrichtigung (Vorab-Info) +Sendet wöchentlich (standardmäßig Freitags) eine HTML-Email mit Terminen, die auf Suchbegriffe passen: +- Sucht nach Begriffen in summary, description und location +- Zeitraum: Samstag bis Freitag der nächsten Woche +- Subject: Bei 1 Termin "Vorab-Info: Termin am Mo, DD.MM.", bei mehreren "Vorab-Info: X Termine nächste Woche" +- Tracking via `weekly_notification_log` Tabelle (verhindert Doppelversand) +- Nur Termine mit Uhrzeit (`all_day=0`), keine Ganztagstermine +- Keine Email wenn keine Treffer +- Konfiguration über WEEKLY_* Umgebungsvariablen +- Funktionen: `parse_weekly_searchwords()`, `get_weekly_events()`, `should_send_weekly()`, `send_weekly_notification()`, `log_weekly_notification()`, `check_and_send_weekly_notification()` + ## Entwicklung ### Lokaler Test (Docker) diff --git a/README.md b/README.md index dc3ab9f..c56b80e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS - 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 +- Optionale wöchentliche Vorab-Info (z.B. freitags) mit Termine nach Suchbegriffen [![UI](docs/screenshot_thumbnail.png)](docs/screenshot.png) @@ -123,6 +124,33 @@ Sendet täglich eine HTML-Email mit den anstehenden Terminen. Wird aktiviert wen **Hinweis:** Ganztagstermine werden nicht in der Benachrichtigung berücksichtigt. +### Optionale wöchentliche Vorab-Info + +Sendet wöchentlich (standardmäßig freitags) eine HTML-Email mit Terminen, die auf konfigurierte Suchbegriffe passen. Zeitraum ist immer Samstag bis Freitag der nächsten Woche. Wird aktiviert wenn `WEEKLY_NOTIFY_ENABLED=true` und mindestens ein Suchbegriff gesetzt ist. + +| Variable | Default | Beschreibung | +|----------|---------|--------------| +| `WEEKLY_NOTIFY_ENABLED` | `false` | Feature aktivieren | +| `WEEKLY_NOTIFY_DAY` | `5` | Wochentag (0=Mo, 1=Di, ..., 5=Fr) | +| `WEEKLY_NOTIFY_TIME` | `16` | Uhrzeit für Benachrichtigung (Stunde, 0-23) | +| `WEEKLY_NOTIFY_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Benachrichtigung | +| `WEEKLY_NOTIFY_EMAIL` | - | Empfänger (Fallback: `NOTIFY_EMAIL`) | +| `WEEKLY_SEARCHWORDS` | - | Komma-separierte Suchbegriffe | + +**Subject-Logik:** +- 1 Termin: `Vorab-Info: Termin am Fr, 15.08.` +- 2+ Termine: `Vorab-Info: 3 Termine naechste Woche` + +**Beispiel:** +```bash +WEEKLY_NOTIFY_ENABLED=true +WEEKLY_NOTIFY_DAY=5 +WEEKLY_NOTIFY_TIME=16 +WEEKLY_SEARCHWORDS=Fussball,Arzttermine +``` + +**Hinweis:** Keine Email wenn keine Treffer für die Suchbegriffe im Zeitraum. + ## Datenbank-Schema Tabelle `calendar_events`: @@ -155,6 +183,15 @@ Tabelle `daily_notification_log` (optional, für E-Mail-Benachrichtigung): | `sent_at` | DATETIME | Zeitpunkt des Versands | | `event_count` | INT | Anzahl Termine in der Email | +Tabelle `weekly_notification_log` (optional, für wöchentliche Vorab-Info): + +| 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 2f3f758..5958648 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,13 @@ services: - NOTIFY_TIME=${NOTIFY_TIME:-6} - NOTIFY_TIMEZONE=${NOTIFY_TIMEZONE:-Europe/Berlin} + - WEEKLY_NOTIFY_ENABLED=${WEEKLY_NOTIFY_ENABLED:-false} + - WEEKLY_NOTIFY_DAY=${WEEKLY_NOTIFY_DAY:-5} + - WEEKLY_NOTIFY_TIME=${WEEKLY_NOTIFY_TIME:-16} + - WEEKLY_NOTIFY_TIMEZONE=${WEEKLY_NOTIFY_TIMEZONE:-Europe/Berlin} + - WEEKLY_NOTIFY_EMAIL=${WEEKLY_NOTIFY_EMAIL:-} + - WEEKLY_SEARCHWORDS=${WEEKLY_SEARCHWORDS:-} + networks: - docker-backend diff --git a/sync.py b/sync.py index 9d68a3e..c64d143 100644 --- a/sync.py +++ b/sync.py @@ -61,6 +61,13 @@ 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") +WEEKLY_NOTIFY_ENABLED = os.environ.get("WEEKLY_NOTIFY_ENABLED", "false").lower() == "true" +WEEKLY_NOTIFY_DAY = int(os.environ.get("WEEKLY_NOTIFY_DAY", "5")) +WEEKLY_NOTIFY_TIME = int(os.environ.get("WEEKLY_NOTIFY_TIME", "16")) +WEEKLY_NOTIFY_TIMEZONE = os.environ.get("WEEKLY_NOTIFY_TIMEZONE", "Europe/Berlin") +WEEKLY_NOTIFY_EMAIL = os.environ.get("WEEKLY_NOTIFY_EMAIL", "") +WEEKLY_SEARCHWORDS = os.environ.get("WEEKLY_SEARCHWORDS", "") + def bootstrap_database(): """Legt DB_NAME und DB_USER an, falls sie noch nicht existieren. @@ -131,6 +138,15 @@ def ensure_schema(conn): UNIQUE KEY uk_date (notify_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS weekly_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() @@ -414,6 +430,179 @@ def check_and_send_notification(): conn.close() +def parse_weekly_searchwords(): + if not WEEKLY_SEARCHWORDS: + return [] + return [w.strip() for w in WEEKLY_SEARCHWORDS.split(",") if w.strip()] + + +def get_weekly_events(conn, calendar_label, searchwords): + tz = ZoneInfo(WEEKLY_NOTIFY_TIMEZONE) + now = datetime.now(tz) + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + + days_until_saturday = (5 - today.weekday()) % 7 + if days_until_saturday == 0: + days_until_saturday = 7 + week_start = today + timedelta(days=days_until_saturday) + week_end = week_start + timedelta(days=6, hours=23, minutes=59, seconds=59) + + week_start_naive = week_start.replace(tzinfo=None) + week_end_naive = week_end.replace(tzinfo=None) + + search_clauses = [] + search_params = [] + for word in searchwords: + like = f"%{word}%" + search_clauses.append("(summary LIKE %s OR description LIKE %s OR location LIKE %s)") + search_params.extend([like, like, like]) + + where_search = " OR ".join(search_clauses) if search_clauses else "1=1" + + cur = conn.cursor(dictionary=True) + cur.execute( + f""" + 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 + AND ({where_search}) + ORDER BY start_at ASC + """, + (calendar_label, week_start_naive, week_end_naive, *search_params), + ) + events = cur.fetchall() + cur.close() + return events, week_start, week_end + + +def should_send_weekly(conn): + if not WEEKLY_NOTIFY_ENABLED: + return False + if not parse_weekly_searchwords(): + return False + + tz = ZoneInfo(WEEKLY_NOTIFY_TIMEZONE) + now = datetime.now(tz) + + if now.weekday() != WEEKLY_NOTIFY_DAY or now.hour != WEEKLY_NOTIFY_TIME: + return False + + cur = conn.cursor() + cur.execute( + "SELECT COUNT(*) FROM weekly_notification_log WHERE notify_date = CURDATE()" + ) + count = cur.fetchone()[0] + cur.close() + return count == 0 + + +def log_weekly_notification(conn, event_count): + cur = conn.cursor() + cur.execute( + "INSERT INTO weekly_notification_log (notify_date, sent_at, event_count) VALUES (CURDATE(), NOW(), %s)", + (event_count,), + ) + conn.commit() + cur.close() + + +def send_weekly_notification(events, searchwords, week_start, week_end): + email = WEEKLY_NOTIFY_EMAIL or NOTIFY_EMAIL + if not SMTP_HOST or not email: + log.warning("SMTP-Konfiguration unvollstaendig, ueberspringe Wochenbenachrichtigung") + return + + tz = ZoneInfo(WEEKLY_NOTIFY_TIMEZONE) + count = len(events) + + start_str = week_start.strftime("%a, %d.%m.") + end_str = week_end.strftime("%a, %d.%m.") + + if count == 1: + event = events[0] + event_date = event["start_at"].replace(tzinfo=timezone.utc).astimezone(tz).strftime("%a, %d.%m.") + subject = f"Vorab-Info: Termin am {event_date}" + else: + subject = f"Vorab-Info: {count} Termine naechste Woche" + + event_rows = "" + for event in events: + time_str = format_event_time(event["start_at"], event.get("end_at")) + event_date = event["start_at"].replace(tzinfo=timezone.utc).astimezone(tz).strftime("%a, %d.%m.") + summary = event["summary"] + location = f" ({event['location']})" if event.get("location") else "" + event_rows += f""" + + {event_date} + {time_str} + {summary}{location} + """ + + words_display = ", ".join(searchwords) + + html = f""" + + + + +

Vorab-Info: Naechste Woche

+

Vom {start_str} bis {end_str} stehen folgende Termine an:

+ + {event_rows} +
+

Suchbegriffe: {words_display}

+ + + """ + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = SMTP_FROM + msg["To"] = 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, [email], msg.as_string()) + server.quit() + log.info("Wochenbenachrichtigung gesendet: %s", subject) + except Exception: + log.exception("Fehler beim Senden der Wochenbenachrichtigung") + + +def check_and_send_weekly_notification(): + if not SMTP_HOST or not (WEEKLY_NOTIFY_EMAIL or NOTIFY_EMAIL): + return + + conn = get_connection(autocommit=True) + try: + if should_send_weekly(conn): + searchwords = parse_weekly_searchwords() + events, week_start, week_end = get_weekly_events(conn, CALENDAR_LABEL, searchwords) + if events: + send_weekly_notification(events, searchwords, week_start, week_end) + log_weekly_notification(conn, len(events)) + log.info("Wochenbenachrichtigung fuer %d Events gesendet", len(events)) + else: + log.info("Keine passenden Termine naechste Woche, Wochenbenachrichtigung wird uebersprungen") + except Exception: + log.exception("Fehler bei der Wochenbenachrichtigung") + finally: + conn.close() + + def main(): bootstrap_database() log.info( @@ -425,6 +614,7 @@ def main(): try: run_sync_once() check_and_send_notification() + check_and_send_weekly_notification() except Exception: log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut") time.sleep(SYNC_INTERVAL_MINUTES * 60)