api / backend /services /email_alerter.py
safraeli's picture
Fix HEAD support for UptimeRobot, email alerter bug, update docs
063a7cd verified
"""
Email alerter — sends notifications when data sources go stale.
Activated by setting env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, ALERT_EMAIL_TO.
Respects a per-source cooldown to avoid spamming.
"""
from __future__ import annotations
import logging
import os
import smtplib
import time
from email.mime.text import MIMEText
from typing import Any
from config.settings import ALERT_COOLDOWN_MIN
log = logging.getLogger("solarwine.alerter")
class EmailAlerter:
"""Sends email alerts when data sources are in 'red' status."""
def __init__(self):
self._last_alert: dict[str, float] = {} # source_name -> epoch of last alert
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._alert_to = os.environ.get("ALERT_EMAIL_TO", "")
self._alert_from = os.environ.get("ALERT_EMAIL_FROM", self._smtp_user)
@property
def enabled(self) -> bool:
return bool(self._smtp_host and self._alert_to)
def check_and_alert(self, status: dict[str, Any]) -> list[str]:
"""Check status and send alerts for red sources. Returns list of alerted sources."""
if not self.enabled:
return []
alerted: list[str] = []
sources = status.get("sources", {})
for source_name, info in sources.items():
if info.get("status") != "red":
# Source recovered — clear cooldown so next outage triggers immediately
self._last_alert.pop(source_name, None)
continue
# Check cooldown
now = time.time()
last = self._last_alert.get(source_name, 0)
if (now - last) < ALERT_COOLDOWN_MIN * 60:
continue
# Send alert
message = info.get("message", f"{source_name} is down")
age = info.get("age_minutes")
subject = f"[SolarWine] Data flow alert: {source_name}"
age_line = f"Age: {age:.0f} min\n" if age is not None else ""
body = (
f"Data source: {source_name}\n"
f"Status: RED\n"
f"{age_line}"
f"Detail: {message}\n"
f"\nChecked at: {status.get('checked_at', 'unknown')}\n"
f"Overall system status: {status.get('overall', 'unknown')}\n"
f"\n---\nSolarWine Data Flow Monitor"
)
if self._send_email(subject, body):
self._last_alert[source_name] = now
alerted.append(source_name)
return alerted
def _send_email(self, subject: str, body: str) -> bool:
"""Send an email via SMTP. Returns True on success."""
try:
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = self._alert_from
msg["To"] = self._alert_to
with smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=10) as server:
server.starttls()
if self._smtp_user and self._smtp_password:
server.login(self._smtp_user, self._smtp_password)
server.sendmail(self._alert_from, self._alert_to.split(","), msg.as_string())
log.info("Alert email sent: %s → %s", subject, self._alert_to)
return True
except Exception as exc:
log.error("Failed to send alert email: %s", exc)
return False