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:
@@ -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<NotificationTestResult | null>(null);
|
||||
|
||||
// Test state
|
||||
const [ntfyTesting, setNtfyTesting] = useState(false);
|
||||
const [ntfyTestResult, setNtfyTestResult] = useState<NotificationTestResult | null>(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() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ntfy */}
|
||||
{/* Notification Channels */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<Bell className="w-4 h-4" /> Push Notifications (ntfy)
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<div>
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<Bell className="w-4 h-4" /> Notification Channels
|
||||
</h2>
|
||||
<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.
|
||||
Use the public <code className="bg-muted px-1 rounded">ntfy.sh</code> server or your own self-hosted instance.
|
||||
</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">
|
||||
<label className="text-sm font-medium">Topic URL</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The full URL to your ntfy topic, e.g.{" "}
|
||||
<code className="bg-muted px-1 rounded">https://ntfy.sh/my-pocketveto-alerts</code>
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://ntfy.sh/your-topic"
|
||||
value={topicUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Authentication</label>
|
||||
<div className="space-y-2">
|
||||
{AUTH_METHODS.map(({ value, label, hint }) => (
|
||||
<label key={value} className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="radio" name="ntfy-auth" value={value} checked={authMethod === value}
|
||||
onChange={() => setAuthMethod(value)} className="mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{hint}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authMethod === "token" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Access Token</label>
|
||||
<input type="password" placeholder="tk_..." value={token}
|
||||
onChange={(e) => 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" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authMethod === "basic" && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input type="text" placeholder="your-username" value={username}
|
||||
onChange={(e) => 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" />
|
||||
<label className="text-sm font-medium">Topic URL</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The full URL to your ntfy topic, e.g.{" "}
|
||||
<code className="bg-muted px-1 rounded">https://ntfy.sh/my-pocketveto-alerts</code>
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://ntfy.sh/your-topic"
|
||||
value={topicUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<input type="password" placeholder="your-password" value={password}
|
||||
onChange={(e) => 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" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 pt-1 border-t border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => saveNtfy(true)} disabled={!topicUrl.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">
|
||||
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
|
||||
{ntfySaved ? "Saved!" : "Save & Enable"}
|
||||
</button>
|
||||
<button onClick={testNtfy} disabled={!topicUrl.trim() || ntfyTesting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors">
|
||||
{ntfyTesting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
|
||||
{ntfyTesting ? "Sending…" : "Test"}
|
||||
</button>
|
||||
{settings?.ntfy_enabled && (
|
||||
<button onClick={() => saveNtfy(false)} disabled={update.isPending}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
Disable
|
||||
</button>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Authentication</label>
|
||||
<div className="space-y-2">
|
||||
{AUTH_METHODS.map(({ value, label, hint }) => (
|
||||
<label key={value} className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="radio" name="ntfy-auth" value={value} checked={authMethod === value}
|
||||
onChange={() => setAuthMethod(value)} className="mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{hint}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authMethod === "token" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Access Token</label>
|
||||
<input type="password" placeholder="tk_..." value={token}
|
||||
onChange={(e) => 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" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authMethod === "basic" && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input type="text" placeholder="your-username" value={username}
|
||||
onChange={(e) => 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" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<input type="password" placeholder="your-password" value={password}
|
||||
onChange={(e) => 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" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 pt-1 border-t border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => saveNtfy(true)} disabled={!topicUrl.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">
|
||||
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
|
||||
{ntfySaved ? "Saved!" : "Save & Enable"}
|
||||
</button>
|
||||
<button onClick={testNtfy} disabled={!topicUrl.trim() || ntfyTesting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors">
|
||||
{ntfyTesting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
|
||||
{ntfyTesting ? "Sending…" : "Test"}
|
||||
</button>
|
||||
{settings?.ntfy_enabled && (
|
||||
<button onClick={() => saveNtfy(false)} disabled={update.isPending}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{ntfyTestResult && <ResultBadge result={ntfyTestResult} />}
|
||||
</div>
|
||||
</div>
|
||||
{ntfyTestResult && <ResultBadge result={ntfyTestResult} />}
|
||||
</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>
|
||||
|
||||
{/* Alert Filters */}
|
||||
|
||||
@@ -249,6 +249,8 @@ export const notificationsAPI = {
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
|
||||
testRss: () =>
|
||||
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) =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
|
||||
getHistory: () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user