feat: email notifications with tabbed channel UI (v0.9.10)
Add email as a second notification channel alongside ntfy: - Tabbed channel selector: ntfy | Email | Telegram (coming soon) | Discord (coming soon) - Active channel shown with green status dot on tab - Email tab: address input, Save & Enable, Test, Disable — same UX pattern as ntfy - Backend: SMTP config in settings (SMTP_HOST/PORT/USER/PASSWORD/FROM/STARTTLS) - Dispatcher: _send_email() helper wired into dispatch_notifications - POST /api/notifications/test/email endpoint with descriptive error messages - Email fires in same window as ntfy (respects quiet hours / digest hold) - Telegram and Discord tabs show coming-soon banners with planned feature description - .env.example documents all SMTP settings with provider examples Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -188,7 +188,17 @@ def dispatch_notifications(self):
|
||||
logger.warning(f"ntfy dispatch failed for event {event.id}: {e}")
|
||||
failed += 1
|
||||
|
||||
# Mark dispatched: ntfy was attempted, or user has no ntfy (RSS-only or neither)
|
||||
email_enabled = prefs.get("email_enabled", False)
|
||||
email_address = prefs.get("email_address", "").strip()
|
||||
if email_enabled and email_address:
|
||||
try:
|
||||
_send_email(event, email_address)
|
||||
sent += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"email dispatch failed for event {event.id}: {e}")
|
||||
failed += 1
|
||||
|
||||
# Mark dispatched: channels were attempted, or user has no channels configured (RSS-only)
|
||||
event.dispatched_at = now
|
||||
db.commit()
|
||||
|
||||
@@ -275,6 +285,52 @@ def _build_reason(payload: dict) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _send_email(event: NotificationEvent, email_address: str) -> None:
|
||||
"""Send a plain-text email notification via SMTP."""
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from app.config import settings as app_settings
|
||||
|
||||
if not app_settings.SMTP_HOST or not email_address:
|
||||
return
|
||||
|
||||
payload = event.payload or {}
|
||||
bill_label = payload.get("bill_label", event.bill_id.upper())
|
||||
bill_title = payload.get("bill_title", "")
|
||||
event_label = _EVENT_TITLES.get(event.event_type, "Bill Update")
|
||||
|
||||
subject = f"PocketVeto: {event_label} — {bill_label}"
|
||||
|
||||
lines = [f"{event_label}: {bill_label}"]
|
||||
if bill_title:
|
||||
lines.append(bill_title)
|
||||
if payload.get("brief_summary"):
|
||||
lines.append("")
|
||||
lines.append(payload["brief_summary"][:500])
|
||||
reason = _build_reason(payload)
|
||||
if reason:
|
||||
lines.append("")
|
||||
lines.append(reason)
|
||||
if payload.get("bill_url"):
|
||||
lines.append("")
|
||||
lines.append(f"View bill: {payload['bill_url']}")
|
||||
body = "\n".join(lines)
|
||||
|
||||
from_addr = app_settings.SMTP_FROM or app_settings.SMTP_USER
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = email_address
|
||||
|
||||
with smtplib.SMTP(app_settings.SMTP_HOST, app_settings.SMTP_PORT, timeout=10) as s:
|
||||
if app_settings.SMTP_STARTTLS:
|
||||
s.starttls()
|
||||
if app_settings.SMTP_USER:
|
||||
s.login(app_settings.SMTP_USER, app_settings.SMTP_PASSWORD)
|
||||
s.sendmail(from_addr, [email_address], msg.as_string())
|
||||
|
||||
|
||||
def _send_ntfy(
|
||||
event: NotificationEvent,
|
||||
topic_url: str,
|
||||
|
||||
Reference in New Issue
Block a user