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:
14
.env.example
14
.env.example
@@ -59,3 +59,17 @@ NEWSAPI_KEY=
|
|||||||
|
|
||||||
# ─── Google Trends ────────────────────────────────────────────────────────────
|
# ─── Google Trends ────────────────────────────────────────────────────────────
|
||||||
PYTRENDS_ENABLED=true
|
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=<api-token> PASSWORD=<api-token>
|
||||||
|
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
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin
|
|||||||
ntfy_enabled=prefs.get("ntfy_enabled", False),
|
ntfy_enabled=prefs.get("ntfy_enabled", False),
|
||||||
rss_enabled=prefs.get("rss_enabled", False),
|
rss_enabled=prefs.get("rss_enabled", False),
|
||||||
rss_token=rss_token,
|
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_enabled=prefs.get("digest_enabled", False),
|
||||||
digest_frequency=prefs.get("digest_frequency", "daily"),
|
digest_frequency=prefs.get("digest_frequency", "daily"),
|
||||||
quiet_hours_start=prefs.get("quiet_hours_start"),
|
quiet_hours_start=prefs.get("quiet_hours_start"),
|
||||||
@@ -91,6 +93,10 @@ async def update_notification_settings(
|
|||||||
prefs["ntfy_enabled"] = body.ntfy_enabled
|
prefs["ntfy_enabled"] = body.ntfy_enabled
|
||||||
if body.rss_enabled is not None:
|
if body.rss_enabled is not None:
|
||||||
prefs["rss_enabled"] = body.rss_enabled
|
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:
|
if body.digest_enabled is not None:
|
||||||
prefs["digest_enabled"] = body.digest_enabled
|
prefs["digest_enabled"] = body.digest_enabled
|
||||||
if body.digest_frequency is not None:
|
if body.digest_frequency is not None:
|
||||||
@@ -175,6 +181,54 @@ async def test_ntfy(
|
|||||||
return NotificationTestResult(status="error", detail=f"Connection error: {e}")
|
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)
|
@router.post("/test/rss", response_model=NotificationTestResult)
|
||||||
async def test_rss(
|
async def test_rss(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ class Settings(BaseSettings):
|
|||||||
# pytrends
|
# pytrends
|
||||||
PYTRENDS_ENABLED: bool = True
|
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
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class NotificationSettingsResponse(BaseModel):
|
|||||||
ntfy_enabled: bool = False
|
ntfy_enabled: bool = False
|
||||||
rss_enabled: bool = False
|
rss_enabled: bool = False
|
||||||
rss_token: Optional[str] = None
|
rss_token: Optional[str] = None
|
||||||
|
email_enabled: bool = False
|
||||||
|
email_address: str = ""
|
||||||
# Digest
|
# Digest
|
||||||
digest_enabled: bool = False
|
digest_enabled: bool = False
|
||||||
digest_frequency: str = "daily" # daily | weekly
|
digest_frequency: str = "daily" # daily | weekly
|
||||||
@@ -55,6 +57,8 @@ class NotificationSettingsUpdate(BaseModel):
|
|||||||
ntfy_password: Optional[str] = None
|
ntfy_password: Optional[str] = None
|
||||||
ntfy_enabled: Optional[bool] = None
|
ntfy_enabled: Optional[bool] = None
|
||||||
rss_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_enabled: Optional[bool] = None
|
||||||
digest_frequency: Optional[str] = None
|
digest_frequency: Optional[str] = None
|
||||||
quiet_hours_start: Optional[int] = None
|
quiet_hours_start: Optional[int] = None
|
||||||
|
|||||||
@@ -188,7 +188,17 @@ def dispatch_notifications(self):
|
|||||||
logger.warning(f"ntfy dispatch failed for event {event.id}: {e}")
|
logger.warning(f"ntfy dispatch failed for event {event.id}: {e}")
|
||||||
failed += 1
|
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
|
event.dispatched_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -275,6 +285,52 @@ def _build_reason(payload: dict) -> str | None:
|
|||||||
return 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(
|
def _send_ntfy(
|
||||||
event: NotificationEvent,
|
event: NotificationEvent,
|
||||||
topic_url: str,
|
topic_url: str,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueries } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
|
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
|
||||||
FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, X,
|
FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, X,
|
||||||
|
Mail, Send, MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notificationsAPI, membersAPI, type NotificationTestResult } from "@/lib/api";
|
import { notificationsAPI, membersAPI, type NotificationTestResult } from "@/lib/api";
|
||||||
@@ -204,6 +205,9 @@ export default function NotificationsPage() {
|
|||||||
onSuccess: () => refetch(),
|
onSuccess: () => refetch(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Channel tab
|
||||||
|
const [activeChannelTab, setActiveChannelTab] = useState<"ntfy" | "email" | "telegram" | "discord">("ntfy");
|
||||||
|
|
||||||
// ntfy form state
|
// ntfy form state
|
||||||
const [topicUrl, setTopicUrl] = useState("");
|
const [topicUrl, setTopicUrl] = useState("");
|
||||||
const [authMethod, setAuthMethod] = useState("none");
|
const [authMethod, setAuthMethod] = useState("none");
|
||||||
@@ -212,6 +216,13 @@ export default function NotificationsPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [ntfySaved, setNtfySaved] = useState(false);
|
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<NotificationTestResult | null>(null);
|
||||||
|
|
||||||
// Test state
|
// Test state
|
||||||
const [ntfyTesting, setNtfyTesting] = useState(false);
|
const [ntfyTesting, setNtfyTesting] = useState(false);
|
||||||
const [ntfyTestResult, setNtfyTestResult] = useState<NotificationTestResult | null>(null);
|
const [ntfyTestResult, setNtfyTestResult] = useState<NotificationTestResult | null>(null);
|
||||||
@@ -287,6 +298,8 @@ export default function NotificationsPage() {
|
|||||||
setToken(settings.ntfy_token ?? "");
|
setToken(settings.ntfy_token ?? "");
|
||||||
setUsername(settings.ntfy_username ?? "");
|
setUsername(settings.ntfy_username ?? "");
|
||||||
setPassword(settings.ntfy_password ?? "");
|
setPassword(settings.ntfy_password ?? "");
|
||||||
|
setEmailAddress(settings.email_address ?? "");
|
||||||
|
setEmailEnabled(settings.email_enabled ?? false);
|
||||||
setDigestEnabled(settings.digest_enabled ?? false);
|
setDigestEnabled(settings.digest_enabled ?? false);
|
||||||
setDigestFrequency(settings.digest_frequency ?? "daily");
|
setDigestFrequency(settings.digest_frequency ?? "daily");
|
||||||
setSavedTimezone(settings.timezone ?? null);
|
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) => {
|
const toggleRss = (enabled: boolean) => {
|
||||||
update.mutate(
|
update.mutate(
|
||||||
{ rss_enabled: enabled },
|
{ rss_enabled: enabled },
|
||||||
@@ -436,24 +472,48 @@ export default function NotificationsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ntfy */}
|
{/* Notification Channels */}
|
||||||
<section className="bg-card border border-border rounded-lg p-6 space-y-5">
|
<section className="bg-card border border-border rounded-lg p-6 space-y-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold flex items-center gap-2">
|
<h2 className="font-semibold flex items-center gap-2">
|
||||||
<Bell className="w-4 h-4" /> Push Notifications (ntfy)
|
<Bell className="w-4 h-4" /> Notification Channels
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Configure where you receive push notifications. Enable one or more channels.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel tab bar */}
|
||||||
|
<div className="flex gap-0 border-b border-border -mx-6 px-6">
|
||||||
|
{([
|
||||||
|
{ 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 }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setActiveChannelTab(key)}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
|
activeChannelTab === key
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
{active && <span className="w-1.5 h-1.5 rounded-full bg-green-500 ml-0.5" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ntfy tab */}
|
||||||
|
{activeChannelTab === "ntfy" && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
Uses <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a> — a free, open-source push notification service.
|
Uses <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a> — a free, open-source push notification service.
|
||||||
Use the public <code className="bg-muted px-1 rounded">ntfy.sh</code> server or your own self-hosted instance.
|
Use the public <code className="bg-muted px-1 rounded">ntfy.sh</code> server or your own self-hosted instance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
{settings?.ntfy_enabled && (
|
|
||||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
|
|
||||||
<CheckCircle className="w-3.5 h-3.5" /> Active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Topic URL</label>
|
<label className="text-sm font-medium">Topic URL</label>
|
||||||
@@ -533,6 +593,85 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{ntfyTestResult && <ResultBadge result={ntfyTestResult} />}
|
{ntfyTestResult && <ResultBadge result={ntfyTestResult} />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email tab */}
|
||||||
|
{activeChannelTab === "email" && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Receive bill alerts as plain-text emails. Requires SMTP to be configured on the server (see <code className="bg-muted px-1 rounded">.env</code>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium">Delivery Address</label>
|
||||||
|
<p className="text-xs text-muted-foreground">The email address to send alerts to.</p>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={emailAddress}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-1 border-t border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => saveEmail(true)} disabled={!emailAddress.trim() || update.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors">
|
||||||
|
{emailSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
|
||||||
|
{emailSaved ? "Saved!" : "Save & Enable"}
|
||||||
|
</button>
|
||||||
|
<button onClick={testEmailFn} disabled={!emailAddress.trim() || emailTesting}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors">
|
||||||
|
{emailTesting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
|
||||||
|
{emailTesting ? "Sending…" : "Test"}
|
||||||
|
</button>
|
||||||
|
{settings?.email_enabled && (
|
||||||
|
<button onClick={() => saveEmail(false)} disabled={update.isPending}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{emailTestResult && <ResultBadge result={emailTestResult} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Telegram tab — coming soon */}
|
||||||
|
{activeChannelTab === "telegram" && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 space-y-4 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Send className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="font-medium">Telegram Notifications</p>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs leading-relaxed">
|
||||||
|
Receive PocketVeto alerts directly in Telegram via a dedicated bot.
|
||||||
|
Will require a Telegram Bot Token and your Chat ID.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs px-3 py-1 bg-muted rounded-full text-muted-foreground font-medium">Coming soon</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discord tab — coming soon */}
|
||||||
|
{activeChannelTab === "discord" && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 space-y-4 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="font-medium">Discord Notifications</p>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs leading-relaxed">
|
||||||
|
Post bill alerts to a Discord channel via a webhook URL.
|
||||||
|
Will support per-channel routing and @role mentions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs px-3 py-1 bg-muted rounded-full text-muted-foreground font-medium">Coming soon</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Alert Filters */}
|
{/* Alert Filters */}
|
||||||
|
|||||||
@@ -249,6 +249,8 @@ export const notificationsAPI = {
|
|||||||
apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
|
apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
|
||||||
testRss: () =>
|
testRss: () =>
|
||||||
apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data),
|
apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data),
|
||||||
|
testEmail: () =>
|
||||||
|
apiClient.post<NotificationTestResult>("/api/notifications/test/email").then((r) => r.data),
|
||||||
testFollowMode: (mode: string, event_type: string) =>
|
testFollowMode: (mode: string, event_type: string) =>
|
||||||
apiClient.post<NotificationTestResult>("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
|
apiClient.post<NotificationTestResult>("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
|
||||||
getHistory: () =>
|
getHistory: () =>
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ export interface NotificationSettings {
|
|||||||
ntfy_enabled: boolean;
|
ntfy_enabled: boolean;
|
||||||
rss_enabled: boolean;
|
rss_enabled: boolean;
|
||||||
rss_token: string | null;
|
rss_token: string | null;
|
||||||
|
email_enabled: boolean;
|
||||||
|
email_address: string;
|
||||||
digest_enabled: boolean;
|
digest_enabled: boolean;
|
||||||
digest_frequency: "daily" | "weekly";
|
digest_frequency: "daily" | "weekly";
|
||||||
quiet_hours_start: number | null;
|
quiet_hours_start: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user