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" - /> -
- -
- -
- {AUTH_METHODS.map(({ value, label, hint }) => ( - - ))} -
-
- - {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 && ( - +
+ +
+ {AUTH_METHODS.map(({ value, label, hint }) => ( + + ))} +
+
+ + {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" /> +
+
+ + 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 && ( + + )} +
+ {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;