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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user