mirror of
https://github.com/skoelle/calender_sync.git
synced 2026-09-17 18:20:24 +00:00
mailer weekly notifier
This commit is contained in:
@@ -38,3 +38,11 @@ API_PORT=8000
|
|||||||
# NOTIFY_EMAIL=empfaenger@example.com
|
# NOTIFY_EMAIL=empfaenger@example.com
|
||||||
# NOTIFY_TIME=6
|
# NOTIFY_TIME=6
|
||||||
# NOTIFY_TIMEZONE=Europe/Berlin
|
# 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
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ Sendet täglich um konfigurierte Uhrzeit eine HTML-Email mit anstehenden Termine
|
|||||||
- Konfiguration über SMTP_* und NOTIFY_* Umgebungsvariablen
|
- Konfiguration über SMTP_* und NOTIFY_* Umgebungsvariablen
|
||||||
- Funktionen: `get_today_events()`, `should_notify()`, `send_notification()`, `log_notification()`
|
- 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
|
## Entwicklung
|
||||||
|
|
||||||
### Lokaler Test (Docker)
|
### Lokaler Test (Docker)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
|
|||||||
- Web-UI zur Anzeige anstehender Termine mit Suchfunktion
|
- Web-UI zur Anzeige anstehender Termine mit Suchfunktion
|
||||||
- REST API für programmsprachigen Zugriff auf Kalenderdaten
|
- REST API für programmsprachigen Zugriff auf Kalenderdaten
|
||||||
- Optionale tägliche E-Mail-Benachrichtigung um konfigurierte Uhrzeit
|
- Optionale tägliche E-Mail-Benachrichtigung um konfigurierte Uhrzeit
|
||||||
|
- Optionale wöchentliche Vorab-Info (z.B. freitags) mit Termine nach Suchbegriffen
|
||||||
|
|
||||||
[](docs/screenshot.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.
|
**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
|
## Datenbank-Schema
|
||||||
|
|
||||||
Tabelle `calendar_events`:
|
Tabelle `calendar_events`:
|
||||||
@@ -155,6 +183,15 @@ Tabelle `daily_notification_log` (optional, für E-Mail-Benachrichtigung):
|
|||||||
| `sent_at` | DATETIME | Zeitpunkt des Versands |
|
| `sent_at` | DATETIME | Zeitpunkt des Versands |
|
||||||
| `event_count` | INT | Anzahl Termine in der Email |
|
| `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
|
## 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.
|
Das Projekt enthält eine FastAPI-basierte Webanwendung die als separater Container (`calendar-api`) läuft und auf Port `8000` erreichbar ist.
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ services:
|
|||||||
- NOTIFY_TIME=${NOTIFY_TIME:-6}
|
- NOTIFY_TIME=${NOTIFY_TIME:-6}
|
||||||
- NOTIFY_TIMEZONE=${NOTIFY_TIMEZONE:-Europe/Berlin}
|
- 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:
|
networks:
|
||||||
- docker-backend
|
- docker-backend
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ NOTIFY_EMAIL = os.environ.get("NOTIFY_EMAIL", "")
|
|||||||
NOTIFY_TIME = int(os.environ.get("NOTIFY_TIME", "6"))
|
NOTIFY_TIME = int(os.environ.get("NOTIFY_TIME", "6"))
|
||||||
NOTIFY_TIMEZONE = os.environ.get("NOTIFY_TIMEZONE", "Europe/Berlin")
|
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():
|
def bootstrap_database():
|
||||||
"""Legt DB_NAME und DB_USER an, falls sie noch nicht existieren.
|
"""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)
|
UNIQUE KEY uk_date (notify_date)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
@@ -414,6 +430,179 @@ def check_and_send_notification():
|
|||||||
conn.close()
|
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"""
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; font-weight: bold; white-space: nowrap;">{event_date}</td>
|
||||||
|
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; white-space: nowrap;">{time_str}</td>
|
||||||
|
<td style="padding: 8px 12px; border-bottom: 1px solid #eee;">{summary}{location}</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
words_display = ", ".join(searchwords)
|
||||||
|
|
||||||
|
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;">Vorab-Info: Naechste Woche</h2>
|
||||||
|
<p style="color: #555;">Vom <strong>{start_str}</strong> bis <strong>{end_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;">Suchbegriffe: {words_display}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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():
|
def main():
|
||||||
bootstrap_database()
|
bootstrap_database()
|
||||||
log.info(
|
log.info(
|
||||||
@@ -425,6 +614,7 @@ def main():
|
|||||||
try:
|
try:
|
||||||
run_sync_once()
|
run_sync_once()
|
||||||
check_and_send_notification()
|
check_and_send_notification()
|
||||||
|
check_and_send_weekly_notification()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut")
|
log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut")
|
||||||
time.sleep(SYNC_INTERVAL_MINUTES * 60)
|
time.sleep(SYNC_INTERVAL_MINUTES * 60)
|
||||||
|
|||||||
Reference in New Issue
Block a user