From 49bda16ad57364e151b23cf0135a66c0730ba549 Mon Sep 17 00:00:00 2001
From: Jack Levy
Date: Sat, 14 Mar 2026 18:46:26 -0400
Subject: [PATCH] feat: email notifications with tabbed channel UI (v0.9.10)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.env.example | 14 +
backend/app/api/notifications.py | 54 +++
backend/app/config.py | 8 +
backend/app/schemas/schemas.py | 4 +
.../app/workers/notification_dispatcher.py | 58 +++-
frontend/app/notifications/page.tsx | 313 +++++++++++++-----
frontend/lib/api.ts | 2 +
frontend/lib/types.ts | 2 +
8 files changed, 367 insertions(+), 88 deletions(-)
diff --git a/.env.example b/.env.example
index 89b1187..7e20d2c 100644
--- a/.env.example
+++ b/.env.example
@@ -59,3 +59,17 @@ NEWSAPI_KEY=
# ─── Google Trends ────────────────────────────────────────────────────────────
PYTRENDS_ENABLED=true
+
+# ─── SMTP (Email Notifications) ───────────────────────────────────────────────
+# Leave SMTP_HOST blank to disable email notifications entirely.
+# Supports any standard SMTP server (Gmail, Outlook, Postmark, Mailgun, etc.)
+# Gmail example: HOST=smtp.gmail.com PORT=587 USER=you@gmail.com (use App Password)
+# Postmark example: HOST=smtp.postmarkapp.com PORT=587 USER= PASSWORD=
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_USER=
+SMTP_PASSWORD=
+# From address shown in the email header — defaults to SMTP_USER if blank
+SMTP_FROM=
+# Set to false only if your SMTP server uses implicit TLS on port 465
+SMTP_STARTTLS=true
diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py
index bf25a55..1cd90f0 100644
--- a/backend/app/api/notifications.py
+++ b/backend/app/api/notifications.py
@@ -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),
diff --git a/backend/app/config.py b/backend/app/config.py
index f94d3ba..5ff2bf7 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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:
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py
index 9ce9a72..3799d92 100644
--- a/backend/app/schemas/schemas.py
+++ b/backend/app/schemas/schemas.py
@@ -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
diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py
index c45ce5a..111cac4 100644
--- a/backend/app/workers/notification_dispatcher.py
+++ b/backend/app/workers/notification_dispatcher.py
@@ -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,
diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx
index 08a92b1..e56b211 100644
--- a/frontend/app/notifications/page.tsx
+++ b/frontend/app/notifications/page.tsx
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueries } from "@tanstack/react-query";
import {
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, X,
+ Mail, Send, MessageSquare,
} from "lucide-react";
import Link from "next/link";
import { notificationsAPI, membersAPI, type NotificationTestResult } from "@/lib/api";
@@ -204,6 +205,9 @@ export default function NotificationsPage() {
onSuccess: () => refetch(),
});
+ // Channel tab
+ const [activeChannelTab, setActiveChannelTab] = useState<"ntfy" | "email" | "telegram" | "discord">("ntfy");
+
// ntfy form state
const [topicUrl, setTopicUrl] = useState("");
const [authMethod, setAuthMethod] = useState("none");
@@ -212,6 +216,13 @@ export default function NotificationsPage() {
const [password, setPassword] = useState("");
const [ntfySaved, setNtfySaved] = useState(false);
+ // Email form state
+ const [emailAddress, setEmailAddress] = useState("");
+ const [emailEnabled, setEmailEnabled] = useState(false);
+ const [emailSaved, setEmailSaved] = useState(false);
+ const [emailTesting, setEmailTesting] = useState(false);
+ const [emailTestResult, setEmailTestResult] = useState(null);
+
// Test state
const [ntfyTesting, setNtfyTesting] = useState(false);
const [ntfyTestResult, setNtfyTestResult] = useState(null);
@@ -287,6 +298,8 @@ export default function NotificationsPage() {
setToken(settings.ntfy_token ?? "");
setUsername(settings.ntfy_username ?? "");
setPassword(settings.ntfy_password ?? "");
+ setEmailAddress(settings.email_address ?? "");
+ setEmailEnabled(settings.email_enabled ?? false);
setDigestEnabled(settings.digest_enabled ?? false);
setDigestFrequency(settings.digest_frequency ?? "daily");
setSavedTimezone(settings.timezone ?? null);
@@ -327,6 +340,29 @@ export default function NotificationsPage() {
);
};
+ const saveEmail = (enabled: boolean) => {
+ update.mutate(
+ { email_address: emailAddress, email_enabled: enabled },
+ { onSuccess: () => { setEmailSaved(true); setTimeout(() => setEmailSaved(false), 2000); } }
+ );
+ };
+
+ const testEmailFn = async () => {
+ setEmailTesting(true);
+ setEmailTestResult(null);
+ try {
+ const result = await notificationsAPI.testEmail();
+ setEmailTestResult(result);
+ } catch (e: unknown) {
+ const detail =
+ (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
+ ?? (e instanceof Error ? e.message : "Request failed");
+ setEmailTestResult({ status: "error", detail });
+ } finally {
+ setEmailTesting(false);
+ }
+ };
+
const toggleRss = (enabled: boolean) => {
update.mutate(
{ rss_enabled: enabled },
@@ -436,103 +472,206 @@ export default function NotificationsPage() {
- {/* ntfy */}
+ {/* Notification Channels */}
-
-
-
- Push Notifications (ntfy)
-
-
+
+
+ Notification Channels
+
+
+ Configure where you receive push notifications. Enable one or more channels.
+
+
+
+ {/* Channel tab bar */}
+
+ {([
+ { key: "ntfy", label: "ntfy", icon: Bell, active: settings?.ntfy_enabled },
+ { key: "email", label: "Email", icon: Mail, active: settings?.email_enabled },
+ { key: "telegram", label: "Telegram", icon: Send, active: false },
+ { key: "discord", label: "Discord", icon: MessageSquare, active: false },
+ ] as const).map(({ key, label, icon: Icon, active }) => (
+
+ ))}
+
+
+ {/* ntfy tab */}
+ {activeChannelTab === "ntfy" && (
+
+
Uses ntfy — a free, open-source push notification service.
Use the public ntfy.sh server or your own self-hosted instance.
-
- {settings?.ntfy_enabled && (
-
- Active
-
- )}
-
-
-
-
- The full URL to your ntfy topic, e.g.{" "}
- https://ntfy.sh/my-pocketveto-alerts
-
-
setTopicUrl(e.target.value)}
- className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
- />
-
-
-
-
- {authMethod === "token" && (
-
-
- setToken(e.target.value)}
- className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
-
- )}
-
- {authMethod === "basic" && (
-
-
-
setUsername(e.target.value)}
- className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
+
+
+ The full URL to your ntfy topic, e.g.{" "}
+ https://ntfy.sh/my-pocketveto-alerts
+
+
setTopicUrl(e.target.value)}
+ className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
+ />
-
-
- setPassword(e.target.value)}
- className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
-
-
- )}
-
-
-
-
- {settings?.ntfy_enabled && (
-
+
+
+ {authMethod === "token" && (
+
+
+ setToken(e.target.value)}
+ className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
+
)}
+
+ {authMethod === "basic" && (
+
+ )}
+
+
+
+
+
+ {settings?.ntfy_enabled && (
+
+ )}
+
+ {ntfyTestResult &&
}
+
- {ntfyTestResult &&
}
-
+ )}
+
+ {/* Email tab */}
+ {activeChannelTab === "email" && (
+
+
+ Receive bill alerts as plain-text emails. Requires SMTP to be configured on the server (see .env).
+
+
+
+
+
The email address to send alerts to.
+
setEmailAddress(e.target.value)}
+ className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
+ />
+
+
+
+
+
+
+ {settings?.email_enabled && (
+
+ )}
+
+ {emailTestResult &&
}
+
+
+ )}
+
+ {/* Telegram tab — coming soon */}
+ {activeChannelTab === "telegram" && (
+
+
+
+
+
+
Telegram Notifications
+
+ Receive PocketVeto alerts directly in Telegram via a dedicated bot.
+ Will require a Telegram Bot Token and your Chat ID.
+
+
+
Coming soon
+
+ )}
+
+ {/* Discord tab — coming soon */}
+ {activeChannelTab === "discord" && (
+
+
+
+
+
+
Discord Notifications
+
+ Post bill alerts to a Discord channel via a webhook URL.
+ Will support per-channel routing and @role mentions.
+
+
+
Coming soon
+
+ )}
{/* Alert Filters */}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index e4f9ee7..ae8d2f0 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -249,6 +249,8 @@ export const notificationsAPI = {
apiClient.post
("/api/notifications/test/ntfy", data).then((r) => r.data),
testRss: () =>
apiClient.post("/api/notifications/test/rss").then((r) => r.data),
+ testEmail: () =>
+ apiClient.post("/api/notifications/test/email").then((r) => r.data),
testFollowMode: (mode: string, event_type: string) =>
apiClient.post("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
getHistory: () =>
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 1ed7c23..bd55c74 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -202,6 +202,8 @@ export interface NotificationSettings {
ntfy_enabled: boolean;
rss_enabled: boolean;
rss_token: string | null;
+ email_enabled: boolean;
+ email_address: string;
digest_enabled: boolean;
digest_frequency: "daily" | "weekly";
quiet_hours_start: number | null;