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:
Jack Levy
2026-03-14 18:46:26 -04:00
parent 8625c850a0
commit 49bda16ad5
8 changed files with 367 additions and 88 deletions

View File

@@ -45,6 +45,8 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin
ntfy_enabled=prefs.get("ntfy_enabled", False),
rss_enabled=prefs.get("rss_enabled", False),
rss_token=rss_token,
email_enabled=prefs.get("email_enabled", False),
email_address=prefs.get("email_address", ""),
digest_enabled=prefs.get("digest_enabled", False),
digest_frequency=prefs.get("digest_frequency", "daily"),
quiet_hours_start=prefs.get("quiet_hours_start"),
@@ -91,6 +93,10 @@ async def update_notification_settings(
prefs["ntfy_enabled"] = body.ntfy_enabled
if body.rss_enabled is not None:
prefs["rss_enabled"] = body.rss_enabled
if body.email_enabled is not None:
prefs["email_enabled"] = body.email_enabled
if body.email_address is not None:
prefs["email_address"] = body.email_address.strip()
if body.digest_enabled is not None:
prefs["digest_enabled"] = body.digest_enabled
if body.digest_frequency is not None:
@@ -175,6 +181,54 @@ async def test_ntfy(
return NotificationTestResult(status="error", detail=f"Connection error: {e}")
@router.post("/test/email", response_model=NotificationTestResult)
async def test_email(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Send a test email to the user's configured email address."""
import smtplib
from email.mime.text import MIMEText
user = await db.get(User, current_user.id)
prefs = user.notification_prefs or {}
email_addr = prefs.get("email_address", "").strip()
if not email_addr:
return NotificationTestResult(status="error", detail="No email address saved. Save your address first.")
if not app_settings.SMTP_HOST:
return NotificationTestResult(status="error", detail="SMTP not configured on this server. Set SMTP_HOST in .env")
try:
from_addr = app_settings.SMTP_FROM or app_settings.SMTP_USER
base_url = (app_settings.PUBLIC_URL or app_settings.LOCAL_URL).rstrip("/")
body = (
"This is a test email from PocketVeto.\n\n"
"Your email notification settings are working correctly. "
"Real alerts will include bill titles, summaries, and direct links.\n\n"
f"Visit your notifications page: {base_url}/notifications"
)
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = "PocketVeto: Test Email Notification"
msg["From"] = from_addr
msg["To"] = email_addr
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_addr], msg.as_string())
return NotificationTestResult(status="ok", detail=f"Test email sent to {email_addr}")
except smtplib.SMTPAuthenticationError:
return NotificationTestResult(status="error", detail="SMTP authentication failed — check SMTP_USER and SMTP_PASSWORD in .env")
except smtplib.SMTPConnectError:
return NotificationTestResult(status="error", detail=f"Could not connect to {app_settings.SMTP_HOST}:{app_settings.SMTP_PORT}")
except Exception as e:
return NotificationTestResult(status="error", detail=str(e))
@router.post("/test/rss", response_model=NotificationTestResult)
async def test_rss(
current_user: User = Depends(get_current_user),

View File

@@ -54,6 +54,14 @@ class Settings(BaseSettings):
# pytrends
PYTRENDS_ENABLED: bool = True
# SMTP (Email notifications)
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM: str = "" # Defaults to SMTP_USER if blank
SMTP_STARTTLS: bool = True
@lru_cache
def get_settings() -> Settings:

View File

@@ -35,6 +35,8 @@ class NotificationSettingsResponse(BaseModel):
ntfy_enabled: bool = False
rss_enabled: bool = False
rss_token: Optional[str] = None
email_enabled: bool = False
email_address: str = ""
# Digest
digest_enabled: bool = False
digest_frequency: str = "daily" # daily | weekly
@@ -55,6 +57,8 @@ class NotificationSettingsUpdate(BaseModel):
ntfy_password: Optional[str] = None
ntfy_enabled: Optional[bool] = None
rss_enabled: Optional[bool] = None
email_enabled: Optional[bool] = None
email_address: Optional[str] = None
digest_enabled: Optional[bool] = None
digest_frequency: Optional[str] = None
quiet_hours_start: Optional[int] = None

View File

@@ -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,