diff --git a/.env.example b/.env.example index dc1f13f..2ab9fb2 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,14 @@ POLL_INTERVAL_MINUTES=15 # Healthchecks (self-hosted) HEALTHCHECK_PING_URL=https://healthchecks.example.org/ping/change-me +# E-Mail-Notification bei IP-Änderung (optional) +SMTP_HOST=smtp.example.org +SMTP_PORT=587 +SMTP_USER=change-me +SMTP_PASSWORD=change-me +NOTIFY_EMAIL_TO=you@example.org +NOTIFY_EMAIL_FROM=dyndns@example.org + # Sonstiges TZ=Europe/Berlin LOG_LEVEL=INFO diff --git a/PLAN.md b/PLAN.md index bd4e399..c1d92c1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -13,6 +13,7 @@ ## Phase 2 - Kernmodule - [x] config.py, state.py, fritzbox.py, cloudflare.py, freedns.py, healthcheck.py, main.py +- [x] notify.py (E-Mail-Notification bei IP-Aenderung) ## Phase 3 - Lokaler Test - [ ] .env lokal befuellen (nicht committen) diff --git a/README.md b/README.md index be8262e..e937869 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ Optional: `FREEDNS_UPDATE_URL`, `POLL_INTERVAL_MINUTES`, `HEALTHCHECK_PING_URL`, `TZ`, `LOG_LEVEL`. Die IP kommt ausschließlich aus der FritzBox, nie aus Webhook-Parametern. +### E-Mail-Notification (optional) + +Bei IP-Änderung wird eine HTML-E-Mail mit Emojis versendet, wenn SMTP-Konfiguration +vorliegt: + +| Variable | Zweck | +| --- | --- | +| `SMTP_HOST` | SMTP-Server (z.B. `smtp.gmail.com`) | +| `SMTP_PORT` | Port (Default: `587`) | +| `SMTP_USER` | SMTP-Login | +| `SMTP_PASSWORD` | SMTP-Passwort | +| `NOTIFY_EMAIL_TO` | Empfänger-Adresse | +| `NOTIFY_EMAIL_FROM` | Absender-Adresse | + ## Betrieb - **State (bewusst ohne Volume, ephemeral):** zuletzt bekannte IP liegt als diff --git a/SPEC.md b/SPEC.md index e085b44..4998704 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,7 +26,8 @@ die IP bei jedem Trigger selbst ueber die FritzBox TR-064-Schnittstelle. 2. IP_letzte = state.load() 3. Wenn gleich: Poll -> nur Heartbeat-Ping, kein Update. Webhook/Start -> nur Log. 4. Wenn unterschiedlich: Cloudflare-Records updaten, FreeDNS aufrufen, state speichern, - Healthcheck-Erfolgs-Ping. Bei Teilfehler: state NICHT speichern, Fail-Ping. + Healthcheck-Erfolgs-Ping, E-Mail-Notification (optional). Bei Teilfehler: state NICHT + speichern, Fail-Ping. ## 3. Cloudflare @@ -46,7 +47,14 @@ die IP bei jedem Trigger selbst ueber die FritzBox TR-064-Schnittstelle. - FritzBox-TR-064-User mit eingeschraenkten Rechten - Alle Records bleiben DNS-only (kein Proxy-Mode in v1) -## 6. Offene Punkte +## 6. E-Mail-Notification (optional) + +- HTML-E-Mail mit Emojis bei erfolgreicher IP-Aenderung +- SMTP-Auth mit TLS (Port 587) +- Nur wenn SMTP_HOST, NOTIFY_EMAIL_TO und NOTIFY_EMAIL_FROM gesetzt +- Plain-Text-Fallback fuer Clients ohne HTML-Unterstuetzung + +## 7. Offene Punkte - IPv6/AAAA - Proxy-Mode fuer reine HTTP(S)-Subdomains diff --git a/app/config.py b/app/config.py index 0f2fc4b..f373079 100644 --- a/app/config.py +++ b/app/config.py @@ -39,8 +39,19 @@ class Config: self.healthcheck_ping_url = os.environ.get("HEALTHCHECK_PING_URL", "") + self.smtp_host = os.environ.get("SMTP_HOST", "") + self.smtp_port = int(os.environ.get("SMTP_PORT", "587")) + self.smtp_user = os.environ.get("SMTP_USER", "") + self.smtp_password = os.environ.get("SMTP_PASSWORD", "") + self.notify_email_to = os.environ.get("NOTIFY_EMAIL_TO", "") + self.notify_email_from = os.environ.get("NOTIFY_EMAIL_FROM", "") + self.state_path = os.environ.get("STATE_PATH", "/app/data/last-known-ip.json") + @property + def email_notification_enabled(self) -> bool: + return bool(self.smtp_host and self.notify_email_to and self.notify_email_from) + def validate(self): missing = [v for v in REQUIRED_VARS if not os.environ.get(v)] if missing: diff --git a/app/main.py b/app/main.py index 7e461e3..ddbd589 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,7 @@ import threading from apscheduler.schedulers.background import BackgroundScheduler from flask import Flask -from app import cloudflare, freedns, healthcheck, state +from app import cloudflare, freedns, healthcheck, notify, state from app.config import config from app.fritzbox import FritzBoxError, get_external_ip @@ -67,6 +67,7 @@ def run_cycle(trigger: str = "unknown"): status="success", message=f"IP updated to {current_ip}", ) + notify.send_ip_changed_email(last_ip, current_ip) else: failed = {k: v for k, v in cf_results.items() if v != "ok"} log.error( diff --git a/app/notify.py b/app/notify.py new file mode 100644 index 0000000..67f343a --- /dev/null +++ b/app/notify.py @@ -0,0 +1,77 @@ +# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) +# Licensed under the MIT License. See LICENSE file in project root for details. +import logging +import smtplib +from email.message import EmailMessage + +from app.config import config + +log = logging.getLogger("notify") + + +def send_ip_changed_email(old_ip: str | None, new_ip: str) -> bool: + if not config.email_notification_enabled: + log.debug("E-Mail-Notification nicht konfiguriert, überspringe.") + return False + + if old_ip is None: + old_ip = "unbekannt" + + subject = f"🌐 IP-Update: {old_ip} → {new_ip}" + + html = f"""\ + + +
+
🔄
+

+ IP-Adresse geändert +

+
+ + + + + + + + + + + + +
📍 Alte IP + {old_ip} +
⬇️
🆕 Neue IP + {new_ip} +
+
+

+ 📡 DynDNS-Updater · Cloudflare + FreeDNS aktualisiert +

+
+ +""" + + text = f"IP-Adresse geändert: {old_ip} → {new_ip}" + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = config.notify_email_from + msg["To"] = config.notify_email_to + msg.set_content(text) + msg.add_alternative(html, subtype="html") + + try: + with smtplib.SMTP(config.smtp_host, config.smtp_port) as server: + server.ehlo() + server.starttls() + server.ehlo() + server.login(config.smtp_user, config.smtp_password) + server.send_message(msg) + log.info("E-Mail gesendet: %s -> %s", subject, config.notify_email_to) + return True + except Exception as exc: + log.error("E-Mail-Versand fehlgeschlagen: %s", exc) + return False