From aec41e154f0680f2c52756911c7831b4d97cac8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:44:26 +0200 Subject: [PATCH 1/5] feat(notifications): register webhook + email channel types Add WEBHOOK and EMAIL to the channel set, the dispatcher cascade, the per-event default policy, the push-attempt ledger union, and the admin diagnostic config-presence check. Channels are string-typed, so no migration is needed. The dispatcher routes each new type through its sender with the same decrypt + parse + classify path the existing channels use; the cascade orders webhook + email between the self-host relays and the Web Push fallback. Add the webhook + email channel config shapes and their settings-form validation schemas, plus the query-key entries for the two new config reads. --- .../admin/notifications/diagnostic/route.ts | 8 +++ src/lib/notifications/dispatcher.ts | 41 +++++++++++++++- src/lib/notifications/retry-policy.ts | 16 +++++- .../senders/push-attempt-record.ts | 2 +- src/lib/notifications/types.ts | 49 ++++++++++++++++++- src/lib/query-keys/settings.ts | 4 ++ src/lib/validations/notifications.ts | 30 ++++++++++++ 7 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/app/api/admin/notifications/diagnostic/route.ts b/src/app/api/admin/notifications/diagnostic/route.ts index d7f68dec2..b85aef231 100644 --- a/src/app/api/admin/notifications/diagnostic/route.ts +++ b/src/app/api/admin/notifications/diagnostic/route.ts @@ -134,6 +134,14 @@ function isChannelConfigPresent( if (type === "NTFY") { return typeof parsed.topic === "string" && parsed.topic.length > 0; } + if (type === "WEBHOOK") { + return typeof parsed.url === "string" && parsed.url.length > 0; + } + if (type === "EMAIL") { + return ( + typeof parsed.recipient === "string" && parsed.recipient.length > 0 + ); + } return false; } catch { return false; diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 53b6e0aaf..9bbeadc27 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -4,6 +4,8 @@ import type { NotificationPayload, TelegramChannelConfig, NtfyChannelConfig, + WebhookChannelConfig, + EmailChannelConfig, ChannelType, EventType, } from "@/lib/notifications/types"; @@ -12,6 +14,8 @@ import { sendViaTelegram } from "@/lib/notifications/senders/telegram"; import { sendViaNtfy } from "@/lib/notifications/senders/ntfy"; import { sendViaWebPush } from "@/lib/notifications/senders/web-push"; import { sendViaApns } from "@/lib/notifications/senders/apns"; +import { sendViaWebhook } from "@/lib/notifications/senders/webhook"; +import { sendViaEmail } from "@/lib/notifications/senders/email"; import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { isChannelInCooldown, @@ -295,6 +299,34 @@ async function sendToChannel( } return sendViaNtfy(config, payload); } + case "WEBHOOK": { + let config: WebhookChannelConfig; + try { + config = JSON.parse(decrypted) as WebhookChannelConfig; + } catch { + getEvent()?.addWarning("Failed to parse webhook channel config"); + return { + ok: false, + hardReject: false, + reason: "webhook_config_parse_failed", + }; + } + return sendViaWebhook(config, payload); + } + case "EMAIL": { + let config: EmailChannelConfig; + try { + config = JSON.parse(decrypted) as EmailChannelConfig; + } catch { + getEvent()?.addWarning("Failed to parse email channel config"); + return { + ok: false, + hardReject: false, + reason: "email_config_parse_failed", + }; + } + return sendViaEmail(config, payload); + } case "WEB_PUSH": { return sendViaWebPush(payload.userId, payload); } @@ -334,8 +366,15 @@ function channelPriority(type: string): number { return 1; case "NTFY": return 2; - case "WEB_PUSH": + // v1.17.1 — generic webhook + email sit between the self-host-friendly + // relays and Web Push: a user who configures them wants them ahead of the + // browser-must-be-alive fallback. + case "WEBHOOK": return 3; + case "EMAIL": + return 4; + case "WEB_PUSH": + return 5; default: return 99; } diff --git a/src/lib/notifications/retry-policy.ts b/src/lib/notifications/retry-policy.ts index 792afcd40..604007db7 100644 --- a/src/lib/notifications/retry-policy.ts +++ b/src/lib/notifications/retry-policy.ts @@ -115,11 +115,25 @@ export function classifyTelegramError(description: string | undefined): { */ export function classifyHttpStatus( status: number | undefined, - service: "web-push" | "ntfy", + service: "web-push" | "ntfy" | "webhook", ): { hardReject: boolean; reason: string } { if (status === undefined) { return { hardReject: false, reason: `${service}_network_error` }; } + if (service === "webhook") { + // A user-supplied relay (Gotify / Discord / Slack / Matrix / HA). 410 Gone + // and 404 Not Found mean the endpoint was deleted — permanent, do not + // retry. 401/403 mean the shared secret is wrong — also permanent until + // the user fixes the config, so we hard-reject and surface the reason in + // the Settings card rather than burning the backoff schedule on a + // misconfiguration. Everything else (5xx, 429, timeout) is transient. + if (status === 410) return { hardReject: true, reason: "webhook_410_gone" }; + if (status === 404) + return { hardReject: true, reason: "webhook_404_not_found" }; + if (status === 401 || status === 403) + return { hardReject: true, reason: "webhook_auth_rejected" }; + return { hardReject: false, reason: `webhook_${status}` }; + } if (service === "web-push") { // 410 Gone = subscription is permanently invalid (push provider // told us so). 404 = endpoint never existed / was unsubscribed. diff --git a/src/lib/notifications/senders/push-attempt-record.ts b/src/lib/notifications/senders/push-attempt-record.ts index f829eca9a..78b9bfcac 100644 --- a/src/lib/notifications/senders/push-attempt-record.ts +++ b/src/lib/notifications/senders/push-attempt-record.ts @@ -33,7 +33,7 @@ import { getEvent } from "@/lib/logging/context"; */ export interface PushAttemptRecord { userId: string; - channel: "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY"; + channel: "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" | "WEBHOOK" | "EMAIL"; eventType: string; result: "ok" | "error" | "skipped"; reason?: string | null; diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 658f6b03d..c6ee29446 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -4,7 +4,25 @@ * Events: medication reminders, anomalies, compliance, etc. */ -export const CHANNEL_TYPES = ["TELEGRAM", "NTFY", "WEB_PUSH", "APNS"] as const; +export const CHANNEL_TYPES = [ + "TELEGRAM", + "NTFY", + "WEB_PUSH", + "APNS", + // v1.17.1 — generic outbound webhook. The user supplies a public URL and an + // optional shared secret/header; the dispatcher POSTs a small JSON payload + // ({ title, message, eventType, ... }) through `safeFetch` with + // `requirePublicHost: true`, exactly like ntfy. One channel covers Gotify, + // Discord, Slack, Matrix (via a bridge), Home Assistant, and any homelab + // relay that accepts an inbound JSON POST. + "WEBHOOK", + // v1.17.1 — SMTP / email. Alerts + reminders over email — the channel every + // self-hoster already has. The SMTP transport is OPERATOR-configured via + // `SMTP_*` env (absent → channel never offered, like APNs today); the + // per-user config carries only the recipient address. Bodies stay plain text + // (no markdown library — hard rule). + "EMAIL", +] as const; export type ChannelType = (typeof CHANNEL_TYPES)[number]; export const EVENT_TYPES = [ @@ -140,6 +158,8 @@ export const CHANNEL_TYPE_LABELS: Record = { NTFY: "ntfy", WEB_PUSH: "Web Push", APNS: "Apple Push (APNs)", + WEBHOOK: "Webhook", + EMAIL: "Email", }; // ── Channel config shapes (stored as encrypted JSON) ── @@ -155,6 +175,33 @@ export interface NtfyChannelConfig { authToken?: string; } +/** + * Generic-webhook channel config (v1.17.1). The user supplies a public URL and + * an OPTIONAL custom header (e.g. `Authorization: Bearer ` for Gotify, + * or a Discord/Slack incoming-webhook URL needs no header). The dispatcher + * POSTs a JSON body — see `sendViaWebhook` — through `safeFetch` with + * `requirePublicHost: true` so the SSRF floor + DNS-rebinding pin apply to the + * user-supplied host exactly as they do for ntfy. + */ +export interface WebhookChannelConfig { + url: string; + /** Optional header name to attach (e.g. "Authorization"). */ + headerName?: string; + /** Optional header value (e.g. "Bearer "). Encrypted at rest. */ + headerValue?: string; +} + +/** + * Email channel config (v1.17.1). The SMTP TRANSPORT (host/port/auth/from) is + * operator-supplied via `SMTP_*` env and loaded in `email-config.ts`; the only + * per-user value is the recipient address. Splitting it this way keeps SMTP + * credentials out of every user's encrypted channel blob — they live once in + * the operator's env, exactly like the APNs key. + */ +export interface EmailChannelConfig { + recipient: string; +} + // ── Notification payload ── export interface NotificationPayload { diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index 0751f987b..83fdd03c0 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -11,6 +11,10 @@ export const settingsKeys = { settingsGlobalServices: () => ["settings", "global-services"] as const, settingsNtfy: () => ["settings", "ntfy"] as const, + /** v1.17.1 — generic-webhook channel config. */ + settingsWebhook: () => ["settings", "webhook"] as const, + /** v1.17.1 — SMTP / email channel config. */ + settingsEmail: () => ["settings", "email"] as const, settingsReminderThresholds: () => ["settings", "reminder-thresholds"] as const, diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index 55d420063..b33ba9e7b 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -243,6 +243,36 @@ export const ntfySettingsSchema = z.object({ enabled: z.boolean(), }); +/** + * Generic-webhook channel settings (v1.17.1). The URL is user-supplied and + * must pass the SSRF floor (`isPublicUrl`) at input time; the dispatcher + * re-checks it at fetch time via `safeFetch({ requirePublicHost: true })`. The + * optional header name/value carry a shared secret (e.g. Gotify token). + */ +export const webhookSettingsSchema = z.object({ + url: z + .url("Ungültige Webhook-URL") + .max(500) + .refine( + (url) => isPublicUrl(url), + "Webhook-URL darf nicht auf interne Netzwerke zeigen", + ) + .optional() + .or(z.literal("")), + headerName: z.string().max(100).optional().or(z.literal("")), + headerValue: z.string().max(500).optional().or(z.literal("")), + enabled: z.boolean(), +}); + +/** + * Email channel settings (v1.17.1). The SMTP transport is operator-configured; + * the only per-user value is the recipient address. + */ +export const emailSettingsSchema = z.object({ + recipient: z.email("Ungültige E-Mail-Adresse").max(254).optional().or(z.literal("")), + enabled: z.boolean(), +}); + export const webPushSubscriptionSchema = z.object({ endpoint: z .url("Ungültiger Endpoint") From 7532bbcc05ad9f29750c6f0618c3a66f06b40a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:45:50 +0200 Subject: [PATCH 2/5] feat(notifications): add generic webhook channel A single WEBHOOK channel covers Gotify, Discord, Slack, Matrix, and Home Assistant: the user supplies a public URL and an optional shared-secret header, and the dispatcher POSTs a small plain-text JSON envelope. The outbound goes through safeFetch with requirePublicHost so the SSRF floor and the connect-time DNS-rebinding pin apply to the user-supplied host, exactly as they do for ntfy. Responses classify 404/410/401/403 as hard rejects (auto-disable) and 5xx/429/timeout as transient (backoff). Settings card mirrors the ntfy card with a save + test-connection flow; the header value is never returned by the GET and is preserved across a save round-trip when left blank. --- src/app/api/settings/webhook/route.ts | 106 +++++++++++ src/app/api/settings/webhook/test/route.ts | 52 ++++++ src/components/settings/webhook-card.tsx | 174 ++++++++++++++++++ .../senders/__tests__/webhook.test.ts | 140 ++++++++++++++ src/lib/notifications/senders/webhook.ts | 122 ++++++++++++ 5 files changed, 594 insertions(+) create mode 100644 src/app/api/settings/webhook/route.ts create mode 100644 src/app/api/settings/webhook/test/route.ts create mode 100644 src/components/settings/webhook-card.tsx create mode 100644 src/lib/notifications/senders/__tests__/webhook.test.ts create mode 100644 src/lib/notifications/senders/webhook.ts diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts new file mode 100644 index 000000000..591691437 --- /dev/null +++ b/src/app/api/settings/webhook/route.ts @@ -0,0 +1,106 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { webhookSettingsSchema } from "@/lib/validations/notifications"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * Generic-webhook channel config (v1.17.1). + * GET: current config (header value redacted — only presence flagged). + * PUT: upsert config. + * + * Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one + * channel: the user supplies a public URL and an optional shared-secret + * header. SSRF is enforced at input time (`isPublicUrl` in the schema) and + * again at dispatch time (`safeFetch({ requirePublicHost: true })`). + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const channel = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId: user.id, type: "WEBHOOK" } }, + }); + + if (!channel) { + return apiSuccess({ + enabled: false, + url: "", + headerName: "", + hasHeaderValue: false, + }); + } + + const config = JSON.parse(decrypt(channel.config)) as { + url: string; + headerName?: string; + headerValue?: string; + }; + + annotate({ action: { name: "settings.webhook.get" } }); + + return apiSuccess({ + enabled: channel.enabled, + url: config.url, + headerName: config.headerName ?? "", + hasHeaderValue: !!config.headerValue, + }); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + + if (jsonError) return jsonError; + const parsed = webhookSettingsSchema.safeParse(body); + if (!parsed.success) return apiError("Invalid data", 422); + + const { url, headerName, headerValue, enabled } = parsed.data; + + if (enabled && !url) { + return apiError("Webhook URL is required when the webhook is enabled", 422); + } + + // Preserve an existing header value when the client sends an empty one (the + // GET path never returns the secret, so a save round-trip would otherwise + // wipe it). A non-empty value replaces it. + let nextHeaderValue = headerValue || undefined; + if (!nextHeaderValue) { + const existing = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId: user.id, type: "WEBHOOK" } }, + }); + if (existing) { + const prev = JSON.parse(decrypt(existing.config)) as { + headerValue?: string; + }; + nextHeaderValue = prev.headerValue || undefined; + } + } + + const config = JSON.stringify({ + url: url || "", + ...(headerName ? { headerName } : {}), + ...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}), + }); + + const encryptedConfig = encrypt(config); + + await prisma.notificationChannel.upsert({ + where: { userId_type: { userId: user.id, type: "WEBHOOK" } }, + create: { + userId: user.id, + type: "WEBHOOK", + enabled, + config: encryptedConfig, + }, + update: { enabled, config: encryptedConfig }, + }); + + annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } }); + + return apiSuccess({ saved: true }); +}); diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts new file mode 100644 index 000000000..884a648e0 --- /dev/null +++ b/src/app/api/settings/webhook/test/route.ts @@ -0,0 +1,52 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { sendViaWebhook } from "@/lib/notifications/senders/webhook"; +import type { WebhookChannelConfig } from "@/lib/notifications/types"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * POST: send a test notification via the configured generic webhook. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`webhook-test:${user.id}`, 5, 5 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximum 5 tests in 5 minutes", 429); + } + + const channel = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId: user.id, type: "WEBHOOK" } }, + }); + + if (!channel) { + return apiError("Webhook is not configured", 400); + } + + const config = JSON.parse(decrypt(channel.config)) as WebhookChannelConfig; + + if (!config.url) { + return apiError("Webhook URL is required", 400); + } + + const result = await sendViaWebhook(config, { + eventType: "SYSTEM_ALERT", + userId: user.id, + title: "HealthLog Test", + message: "HealthLog: Connection successful! Webhook notifications are active.", + }); + + if (!result.ok) { + return apiError("Failed to send test message", 500); + } + + annotate({ + action: { name: "settings.webhook.test" }, + meta: { success: true }, + }); + + return apiSuccess({ sent: true }); +}); diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx new file mode 100644 index 000000000..bf2a17b69 --- /dev/null +++ b/src/components/settings/webhook-card.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Webhook } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { PasswordInput } from "@/components/ui/password-input"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { TestConnectionButton } from "@/components/settings/test-connection-button"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; + +interface WebhookSettings { + enabled: boolean; + url: string; + headerName: string; + hasHeaderValue: boolean; +} + +export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [url, setUrl] = useState(""); + const [headerName, setHeaderName] = useState(""); + const [headerValue, setHeaderValue] = useState(""); + const [saveMsg, setSaveMsg] = useState(null); + const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( + null, + ); + + const { data: settings } = useQuery({ + queryKey: queryKeys.settingsWebhook(), + queryFn: async () => { + return apiGet("/api/settings/webhook"); + }, + enabled: isAuthenticated, + }); + + // React-recommended sync-from-server pattern (no setState-in-effect). + const settingsKey = settings ? `${settings.url}|${settings.headerName}` : null; + const [seededKey, setSeededKey] = useState(null); + if (settingsKey && settingsKey !== seededKey) { + setSeededKey(settingsKey); + setUrl(settings!.url); + setHeaderName(settings!.headerName); + } + + const save = useMutation({ + mutationFn: async (enabled: boolean) => { + const res = await apiFetchRaw("/api/settings/webhook", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url, + headerName: headerName || undefined, + headerValue: headerValue || undefined, + enabled, + }), + }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error || t("common.error")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); + setSaveMsg(t("settings.saved")); + setSaveMsgType("success"); + setHeaderValue(""); + }, + onError: (err: Error) => { + setSaveMsg(err.message); + setSaveMsgType("error"); + }, + }); + + return ( +
+ + +
+
+ + save.mutate(checked)} + disabled={save.isPending} + /> +
+ +
{ + e.preventDefault(); + save.mutate(settings?.enabled ?? false); + }} + className="space-y-4" + > +
+
+ + setUrl(e.target.value)} + /> +

+ {t("settings.webhookUrlHint")} +

+
+
+ + setHeaderName(e.target.value)} + /> +
+
+ + setHeaderValue(e.target.value)} + /> +
+
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts new file mode 100644 index 000000000..eaddccc2b --- /dev/null +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -0,0 +1,140 @@ +/** + * Generic-webhook sender unit tests (v1.17.1). + * + * Covers: success, hard-reject classification (404/410/401/403), SSRF block + * (safeFetch throws `private_host`), cooldown-relevant transient classification + * (5xx), and the push_attempts ledger write per outcome. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const safeFetchMock = vi.fn(); +const recordPushAttemptMock = vi.fn(); + +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: (...args: unknown[]) => safeFetchMock(...args), + SafeFetchError: class SafeFetchError extends Error { + kind: string; + constructor(message: string, kind: string) { + super(message); + this.kind = kind; + } + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), +})); + +import { sendViaWebhook } from "../webhook"; +import { SafeFetchError } from "@/lib/safe-fetch"; + +const config = { + url: "https://gotify.example.com/message", + headerName: "Authorization", + headerValue: "Bearer secret", +}; + +function payload(over?: Record) { + return { + eventType: "SYSTEM_ALERT" as const, + userId: "user-1", + title: "Title", + message: "Body", + ...over, + }; +} + +beforeEach(() => { + safeFetchMock.mockReset(); + recordPushAttemptMock.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("sendViaWebhook", () => { + it("POSTs through safeFetch with requirePublicHost and returns ok on 200", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(true); + expect(result.statusCode).toBe(200); + // SSRF floor + DNS-rebinding pin: requirePublicHost must be set. + const [, init, opts] = safeFetchMock.mock.calls[0]; + expect((opts as { requirePublicHost?: boolean }).requirePublicHost).toBe( + true, + ); + expect((init as { method?: string }).method).toBe("POST"); + // Custom header is attached. + expect( + (init as { headers?: Record }).headers?.Authorization, + ).toBe("Bearer secret"); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "WEBHOOK", result: "ok" }), + ); + }); + + it("hard-rejects on 410 (endpoint gone)", async () => { + safeFetchMock.mockResolvedValue({ ok: false, status: 410 }); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(true); + expect(result.reason).toBe("webhook_410_gone"); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "WEBHOOK", result: "error" }), + ); + }); + + it("hard-rejects on 401/403 (shared secret wrong)", async () => { + safeFetchMock.mockResolvedValue({ ok: false, status: 403 }); + const result = await sendViaWebhook(config, payload()); + expect(result.hardReject).toBe(true); + expect(result.reason).toBe("webhook_auth_rejected"); + }); + + it("soft-fails on 503 (transient — eligible for backoff)", async () => { + safeFetchMock.mockResolvedValue({ ok: false, status: 503 }); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(false); + expect(result.reason).toBe("webhook_503"); + }); + + it("returns a transient network error when safeFetch blocks a private host (SSRF)", async () => { + safeFetchMock.mockRejectedValue( + new SafeFetchError("refused private host", "private_host"), + ); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(false); + expect(result.reason).toBe("webhook_network_error"); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "WEBHOOK", + result: "error", + reason: "webhook_network_error", + }), + ); + }); + + it("omits the custom header when not configured", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaWebhook({ url: "https://example.com/hook" }, payload()); + const [, init] = safeFetchMock.mock.calls[0]; + expect( + (init as { headers?: Record }).headers?.Authorization, + ).toBeUndefined(); + }); +}); diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts new file mode 100644 index 000000000..68add9be9 --- /dev/null +++ b/src/lib/notifications/senders/webhook.ts @@ -0,0 +1,122 @@ +import type { + WebhookChannelConfig, + NotificationPayload, +} from "@/lib/notifications/types"; +import type { SendOutcome } from "@/lib/notifications/retry-policy"; +import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; +import { getEvent } from "@/lib/logging/context"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { safeFetch } from "@/lib/safe-fetch"; +import { plainPushText } from "@/lib/notifications/strip-emoji"; + +/** + * Send a notification via a generic outbound webhook (v1.17.1). + * + * The user supplies a public URL (and optionally one custom header — e.g. + * `Authorization: Bearer ` for Gotify). We POST a small JSON envelope + * that Gotify / Discord / Slack / Matrix-bridge / Home Assistant / any homelab + * relay can consume. The body is plain text (no markdown — hard rule); the + * `title`/`message` fields are stripped of HTML + decorative emoji on routine + * reminders exactly like the ntfy sender. + * + * Outbound goes through `safeFetch({ requirePublicHost: true })`: the SSRF + * floor plus the connect-time DNS-rebinding pin apply because the host is + * user-supplied. The optional shared-secret header would otherwise leak on a + * rebinding flip to a private address. + * + * Returns a `SendOutcome` so the dispatcher can distinguish hard rejects + * (404/410 endpoint gone, 401/403 secret wrong) from soft errors (5xx, 429, + * network timeout) and apply the right retry / auto-disable policy. + */ +export async function sendViaWebhook( + config: WebhookChannelConfig, + payload: NotificationPayload, +): Promise { + const start = performance.now(); + try { + const headers: Record = { + "Content-Type": "application/json", + }; + if (config.headerName && config.headerValue) { + headers[config.headerName] = config.headerValue; + } + + // Plain-text envelope. The `title` field doubles as Gotify's `title` and + // Discord/Slack ignore unknown keys, so a single shape covers the common + // relays. Discreet mode (cycle privacy) is honoured by the title/body + // already being masked upstream; we also send a generic `eventType` tag so + // a relay rule can route without leaking the cycle event name. + const body = JSON.stringify({ + title: plainPushText(payload.title, payload.eventType), + message: plainPushText( + payload.message.replace(/<[^>]*>/g, ""), + payload.eventType, + ), + eventType: payload.discreet ? "reminder" : payload.eventType, + priority: + payload.eventType === "MEDICATION_REMINDER" ? "high" : "default", + }); + + const res = await safeFetch( + config.url, + { + method: "POST", + headers, + body, + }, + { timeoutMs: 5_000, requirePublicHost: true }, + ); + + getEvent()?.addExternalCall({ + service: "webhook", + method: "sendNotification", + duration_ms: Math.round(performance.now() - start), + status: res.status, + }); + + if (res.ok) { + recordPushAttempt({ + userId: payload.userId, + channel: "WEBHOOK", + eventType: payload.eventType, + result: "ok", + }); + return { ok: true, statusCode: res.status }; + } + const classified = classifyHttpStatus(res.status, "webhook"); + recordPushAttempt({ + userId: payload.userId, + channel: "WEBHOOK", + eventType: payload.eventType, + result: "error", + reason: classified.reason, + }); + return { + ok: false, + statusCode: res.status, + hardReject: classified.hardReject, + reason: classified.reason, + }; + } catch (err) { + const message = err instanceof Error ? err.message : "request_failed"; + getEvent()?.addExternalCall({ + service: "webhook", + method: "sendNotification", + duration_ms: Math.round(performance.now() - start), + error: message, + }); + recordPushAttempt({ + userId: payload.userId, + channel: "WEBHOOK", + eventType: payload.eventType, + result: "error", + reason: "webhook_network_error", + }); + return { + ok: false, + hardReject: false, + reason: "webhook_network_error", + message, + }; + } +} From 909c2aa99e34e11b76c70edcab51543b65874f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:46:12 +0200 Subject: [PATCH 3/5] feat(notifications): add SMTP email channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alerts and reminders over email — the one channel every self-hoster already has. The SMTP transport (host/port/auth/from) is operator-supplied via SMTP_* env and loaded once per process like the APNs key; the only per-user value is the recipient address, so SMTP credentials never enter a user's encrypted channel blob. Absent SMTP env disables the channel cleanly: the dispatcher skips it and the Settings card hides itself. Bodies are plain text — HTML is stripped and routine reminders run through the emoji-strip path; no markdown library is added. SMTP 5xx maps to a hard reject (permanent — auto-disable); connection/4xx errors are transient and ride the backoff schedule. Adds nodemailer (the standard, well-audited mail transport) and its types. --- package.json | 2 + pnpm-lock.yaml | 31 +++- src/app/api/settings/email/route.ts | 82 +++++++++ src/app/api/settings/email/test/route.ts | 54 ++++++ src/components/settings/email-card.tsx | 142 +++++++++++++++ .../senders/__tests__/email-config.test.ts | 84 +++++++++ .../senders/__tests__/email.test.ts | 140 +++++++++++++++ src/lib/notifications/senders/email-config.ts | 78 ++++++++ src/lib/notifications/senders/email.ts | 167 ++++++++++++++++++ 9 files changed, 774 insertions(+), 6 deletions(-) create mode 100644 src/app/api/settings/email/route.ts create mode 100644 src/app/api/settings/email/test/route.ts create mode 100644 src/components/settings/email-card.tsx create mode 100644 src/lib/notifications/senders/__tests__/email-config.test.ts create mode 100644 src/lib/notifications/senders/__tests__/email.test.ts create mode 100644 src/lib/notifications/senders/email-config.ts create mode 100644 src/lib/notifications/senders/email.ts diff --git a/package.json b/package.json index a2e94be17..cff5b9625 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "pg-boss": "^12.18.2", "prisma": "^7.8.0", "qrcode": "^1.5.4", + "nodemailer": "^9.0.0", "radix-ui": "^1.4.3", "react": "19.2.7", "react-dom": "19.2.7", @@ -100,6 +101,7 @@ "@tailwindcss/postcss": "^4", "@testcontainers/postgresql": "^12.0.1", "@types/node": "^25.9.2", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.20.0", "@types/qrcode": "^1.5.6", "@types/react": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f00356f57..36bc054e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + nodemailer: + specifier: ^9.0.0 + version: 9.0.0 p-limit: specifier: ^7.3.0 version: 7.3.0 @@ -153,6 +156,9 @@ importers: '@types/node': specifier: ^25.9.2 version: 25.9.2 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -2857,6 +2863,9 @@ packages: '@types/node@25.9.2': resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/pako@2.0.4': resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} @@ -5187,6 +5196,10 @@ packages: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} + nodemailer@9.0.0: + resolution: {integrity: sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==} + engines: {node: '>=6.0.0'} + non-error@0.1.0: resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} engines: {node: '>=20'} @@ -9335,13 +9348,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2': 1.15.5 '@types/estree@1.0.8': {} @@ -9360,6 +9373,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 18.19.130 + '@types/pako@2.0.4': {} '@types/pg@8.20.0': @@ -9389,15 +9406,15 @@ snapshots: '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2@0.5.52': dependencies: - '@types/node': 25.9.2 + '@types/node': 18.19.130 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -11823,6 +11840,8 @@ snapshots: node-releases@2.0.46: {} + nodemailer@9.0.0: {} + non-error@0.1.0: {} normalize-path@3.0.0: {} @@ -12244,7 +12263,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.2 + '@types/node': 18.19.130 long: 5.3.2 proxy-addr@2.0.7: diff --git a/src/app/api/settings/email/route.ts b/src/app/api/settings/email/route.ts new file mode 100644 index 000000000..f75ca2da2 --- /dev/null +++ b/src/app/api/settings/email/route.ts @@ -0,0 +1,82 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { emailSettingsSchema } from "@/lib/validations/notifications"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { isEmailConfigured } from "@/lib/notifications/senders/email-config"; + +/** + * Email channel config (v1.17.1). + * GET: per-user recipient + opt-in, plus `smtpConfigured` so the UI can hide + * the card on an instance where the operator hasn't set `SMTP_*`. + * PUT: upsert recipient + enabled. + * + * The SMTP transport (host/port/auth/from) is operator-supplied via env; only + * the recipient address is per-user, so SMTP credentials never enter a user's + * encrypted channel blob. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const smtpConfigured = isEmailConfigured(); + + const channel = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId: user.id, type: "EMAIL" } }, + }); + + if (!channel) { + return apiSuccess({ enabled: false, recipient: "", smtpConfigured }); + } + + const config = JSON.parse(decrypt(channel.config)) as { recipient: string }; + + annotate({ action: { name: "settings.email.get" } }); + + return apiSuccess({ + enabled: channel.enabled, + recipient: config.recipient, + smtpConfigured, + }); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + if (!isEmailConfigured()) { + return apiError("Email is not configured on this instance", 400); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + + if (jsonError) return jsonError; + const parsed = emailSettingsSchema.safeParse(body); + if (!parsed.success) return apiError("Invalid data", 422); + + const { recipient, enabled } = parsed.data; + + if (enabled && !recipient) { + return apiError("A recipient address is required when email is enabled", 422); + } + + const config = JSON.stringify({ recipient: recipient || "" }); + const encryptedConfig = encrypt(config); + + await prisma.notificationChannel.upsert({ + where: { userId_type: { userId: user.id, type: "EMAIL" } }, + create: { + userId: user.id, + type: "EMAIL", + enabled, + config: encryptedConfig, + }, + update: { enabled, config: encryptedConfig }, + }); + + annotate({ action: { name: "settings.email.update" }, meta: { enabled } }); + + return apiSuccess({ saved: true }); +}); diff --git a/src/app/api/settings/email/test/route.ts b/src/app/api/settings/email/test/route.ts new file mode 100644 index 000000000..33d6bb522 --- /dev/null +++ b/src/app/api/settings/email/test/route.ts @@ -0,0 +1,54 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { sendViaEmail } from "@/lib/notifications/senders/email"; +import type { EmailChannelConfig } from "@/lib/notifications/types"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { isEmailConfigured } from "@/lib/notifications/senders/email-config"; + +/** + * POST: send a test email to the configured recipient. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + + if (!isEmailConfigured()) { + return apiError("Email is not configured on this instance", 400); + } + + const rl = await checkRateLimit(`email-test:${user.id}`, 5, 5 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximum 5 tests in 5 minutes", 429); + } + + const channel = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId: user.id, type: "EMAIL" } }, + }); + + if (!channel) { + return apiError("Email is not configured", 400); + } + + const config = JSON.parse(decrypt(channel.config)) as EmailChannelConfig; + + if (!config.recipient) { + return apiError("A recipient address is required", 400); + } + + const result = await sendViaEmail(config, { + eventType: "SYSTEM_ALERT", + userId: user.id, + title: "HealthLog Test", + message: "HealthLog: Connection successful! Email notifications are active.", + }); + + if (!result.ok) { + return apiError("Failed to send test email", 500); + } + + annotate({ action: { name: "settings.email.test" }, meta: { success: true } }); + + return apiSuccess({ sent: true }); +}); diff --git a/src/components/settings/email-card.tsx b/src/components/settings/email-card.tsx new file mode 100644 index 000000000..f0b5b5a90 --- /dev/null +++ b/src/components/settings/email-card.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Mail } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { TestConnectionButton } from "@/components/settings/test-connection-button"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; + +interface EmailSettings { + enabled: boolean; + recipient: string; + /** Operator-side: false when SMTP_* env is unset (card hides itself). */ + smtpConfigured: boolean; +} + +export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [recipient, setRecipient] = useState(""); + const [saveMsg, setSaveMsg] = useState(null); + const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( + null, + ); + + const { data: settings } = useQuery({ + queryKey: queryKeys.settingsEmail(), + queryFn: async () => { + return apiGet("/api/settings/email"); + }, + enabled: isAuthenticated, + }); + + // React-recommended sync-from-server pattern (no setState-in-effect). + const settingsKey = settings ? settings.recipient : null; + const [seededKey, setSeededKey] = useState(null); + if (settingsKey !== null && settingsKey !== seededKey) { + setSeededKey(settingsKey); + setRecipient(settings!.recipient); + } + + const save = useMutation({ + mutationFn: async (enabled: boolean) => { + const res = await apiFetchRaw("/api/settings/email", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recipient, enabled }), + }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error || t("common.error")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() }); + setSaveMsg(t("settings.saved")); + setSaveMsgType("success"); + }, + onError: (err: Error) => { + setSaveMsg(err.message); + setSaveMsgType("error"); + }, + }); + + // Operator hasn't configured SMTP — no transport, so no card. The dispatcher + // skips the channel too; surfacing an unusable toggle would only confuse. + if (settings && !settings.smtpConfigured) return null; + + return ( +
+ + +
+
+ + save.mutate(checked)} + disabled={save.isPending} + /> +
+ +
{ + e.preventDefault(); + save.mutate(settings?.enabled ?? false); + }} + className="space-y-4" + > +
+ + setRecipient(e.target.value)} + /> +
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/notifications/senders/__tests__/email-config.test.ts b/src/lib/notifications/senders/__tests__/email-config.test.ts new file mode 100644 index 000000000..4698fc353 --- /dev/null +++ b/src/lib/notifications/senders/__tests__/email-config.test.ts @@ -0,0 +1,84 @@ +/** + * SMTP config loader unit tests (v1.17.1). Mirrors the APNs env-gating + * contract: zero set → disabled (no warning), partial set → disabled (warning), + * all three core vars → configured. SMTP_USER/SMTP_PASS are optional. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn() }), +})); + +import { + loadEmailConfig, + isEmailConfigured, + resetEmailConfigForTesting, +} from "../email-config"; + +const ORIGINAL_ENV = { ...process.env }; + +function clearSmtp() { + for (const key of [ + "SMTP_HOST", + "SMTP_PORT", + "SMTP_FROM", + "SMTP_USER", + "SMTP_PASS", + "SMTP_SECURE", + ]) { + delete process.env[key]; + } +} + +beforeEach(() => { + clearSmtp(); + resetEmailConfigForTesting(); +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + resetEmailConfigForTesting(); +}); + +describe("loadEmailConfig", () => { + it("returns null when nothing is set", () => { + expect(loadEmailConfig()).toBeNull(); + expect(isEmailConfigured()).toBe(false); + }); + + it("returns null when only some core vars are set (partial)", () => { + process.env.SMTP_HOST = "smtp.example.com"; + resetEmailConfigForTesting(); + expect(loadEmailConfig()).toBeNull(); + }); + + it("builds a config from the three core vars (no auth)", () => { + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_PORT = "587"; + process.env.SMTP_FROM = "noreply@example.com"; + resetEmailConfigForTesting(); + + const config = loadEmailConfig(); + expect(config).toEqual({ + host: "smtp.example.com", + port: 587, + secure: false, + from: "noreply@example.com", + }); + expect(isEmailConfigured()).toBe(true); + }); + + it("includes auth when user + pass are set and honours SMTP_SECURE", () => { + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_PORT = "465"; + process.env.SMTP_FROM = "noreply@example.com"; + process.env.SMTP_USER = "u"; + process.env.SMTP_PASS = "p"; + process.env.SMTP_SECURE = "true"; + resetEmailConfigForTesting(); + + const config = loadEmailConfig(); + expect(config?.secure).toBe(true); + expect(config?.auth).toEqual({ user: "u", pass: "p" }); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts new file mode 100644 index 000000000..53066c67a --- /dev/null +++ b/src/lib/notifications/senders/__tests__/email.test.ts @@ -0,0 +1,140 @@ +/** + * SMTP / email sender unit tests (v1.17.1). + * + * Covers: success, transient SMTP failure (4xx/connection — backoff-eligible), + * hard reject (5xx permanent — channel auto-disables), the "SMTP not + * configured" soft-skip, the "no recipient" soft-skip, and the ledger write. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const sendMailMock = vi.fn(); +const createTransportMock = vi.fn((..._args: unknown[]) => ({ + sendMail: sendMailMock, +})); +const loadEmailConfigMock = vi.fn(); +const recordPushAttemptMock = vi.fn(); + +vi.mock("nodemailer", () => ({ + default: { + createTransport: (...args: unknown[]) => createTransportMock(...args), + }, +})); + +vi.mock("@/lib/notifications/senders/email-config", () => ({ + loadEmailConfig: () => loadEmailConfigMock(), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), +})); + +import { + sendViaEmail, + resetEmailTransporterForTesting, +} from "../email"; + +const transport = { + host: "smtp.example.com", + port: 587, + secure: false, + from: "HealthLog ", + auth: { user: "u", pass: "p" }, +}; + +function payload(over?: Record) { + return { + eventType: "SYSTEM_ALERT" as const, + userId: "user-1", + title: "Subject", + message: "Body", + ...over, + }; +} + +beforeEach(() => { + sendMailMock.mockReset(); + createTransportMock.mockClear(); + loadEmailConfigMock.mockReset(); + recordPushAttemptMock.mockReset(); + resetEmailTransporterForTesting(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("sendViaEmail", () => { + it("sends a plain-text email and returns ok", async () => { + loadEmailConfigMock.mockReturnValue(transport); + sendMailMock.mockResolvedValue({ messageId: "1" }); + + const result = await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(result.ok).toBe(true); + const mail = sendMailMock.mock.calls[0][0]; + expect(mail.to).toBe("you@example.com"); + expect(mail.from).toBe(transport.from); + // Body is plain text — HTML stripped, no `html` field. + expect(mail.text).toBe("Body"); + expect(mail.html).toBeUndefined(); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "EMAIL", result: "ok" }), + ); + }); + + it("soft-skips when SMTP is unconfigured (no channel burn)", async () => { + loadEmailConfigMock.mockReturnValue(null); + + const result = await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(false); + expect(result.reason).toBe("email_not_configured"); + expect(sendMailMock).not.toHaveBeenCalled(); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "EMAIL", result: "skipped" }), + ); + }); + + it("soft-skips when the recipient is empty", async () => { + loadEmailConfigMock.mockReturnValue(transport); + + const result = await sendViaEmail({ recipient: "" }, payload()); + + expect(result.ok).toBe(false); + expect(result.reason).toBe("email_no_recipient"); + expect(sendMailMock).not.toHaveBeenCalled(); + }); + + it("hard-rejects on an SMTP 5xx (permanent — auto-disable)", async () => { + loadEmailConfigMock.mockReturnValue(transport); + const err = Object.assign(new Error("550 mailbox unavailable"), { + responseCode: 550, + }); + sendMailMock.mockRejectedValue(err); + + const result = await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(true); + expect(result.reason).toBe("email_smtp_5xx"); + }); + + it("soft-fails on a connection error (transient — backoff-eligible)", async () => { + loadEmailConfigMock.mockReturnValue(transport); + sendMailMock.mockRejectedValue(new Error("ECONNREFUSED")); + + const result = await sendViaEmail({ recipient: "you@example.com" }, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(false); + expect(result.reason).toBe("email_smtp_error"); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "EMAIL", result: "error" }), + ); + }); +}); diff --git a/src/lib/notifications/senders/email-config.ts b/src/lib/notifications/senders/email-config.ts new file mode 100644 index 000000000..0a7eacd8b --- /dev/null +++ b/src/lib/notifications/senders/email-config.ts @@ -0,0 +1,78 @@ +import { getEvent } from "@/lib/logging/context"; + +/** + * Operator-supplied SMTP transport config (v1.17.1). + * + * Mirrors the APNs pattern (`loadApnsConfig`): the transport credentials live + * once in the operator's env, NOT in every user's encrypted channel blob. When + * the required vars are absent the channel is simply never offered — a + * self-hoster who hasn't set up SMTP sees no Email card and the dispatcher + * skips the channel cleanly rather than throwing on every send. + * + * Required: SMTP_HOST, SMTP_PORT, SMTP_FROM. + * Optional: SMTP_USER + SMTP_PASS (omit for an unauthenticated relay), + * SMTP_SECURE ("true" → implicit TLS on connect, typically port 465; + * default false → STARTTLS upgrade, typically port 587). + */ +export interface EmailTransportConfig { + host: string; + port: number; + secure: boolean; + from: string; + auth?: { user: string; pass: string }; +} + +let cachedConfig: EmailTransportConfig | null | undefined; + +export function loadEmailConfig(): EmailTransportConfig | null { + if (cachedConfig !== undefined) return cachedConfig; + + const host = process.env.SMTP_HOST?.trim() || ""; + const portRaw = process.env.SMTP_PORT?.trim() || ""; + const from = process.env.SMTP_FROM?.trim() || ""; + const user = process.env.SMTP_USER?.trim() || ""; + const pass = process.env.SMTP_PASS?.trim() || ""; + const secure = process.env.SMTP_SECURE?.trim().toLowerCase() === "true"; + + // All-or-none guard mirroring APNs: zero set → silently disabled (no + // warning). Some-but-not-all set → the operator started configuring and + // stopped; surface that as a warning so the half-finished state is visible + // the moment a send is attempted. + const anySet = Boolean(host || portRaw || from || user || pass); + const port = Number(portRaw); + const allSet = Boolean(host && portRaw && from && Number.isFinite(port)); + + if (!anySet) { + cachedConfig = null; + return null; + } + + if (!allSet) { + getEvent()?.addWarning( + "SMTP config incomplete — set SMTP_HOST, SMTP_PORT, and SMTP_FROM " + + "together (SMTP_USER/SMTP_PASS optional). Email channel is disabled " + + "until all three are present.", + ); + cachedConfig = null; + return null; + } + + cachedConfig = { + host, + port, + secure, + from, + ...(user && pass ? { auth: { user, pass } } : {}), + }; + return cachedConfig; +} + +/** True when the operator has configured an SMTP transport. */ +export function isEmailConfigured(): boolean { + return loadEmailConfig() !== null; +} + +/** Test-only: clear the per-process cache so env changes take effect. */ +export function resetEmailConfigForTesting(): void { + cachedConfig = undefined; +} diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts new file mode 100644 index 000000000..289c7490b --- /dev/null +++ b/src/lib/notifications/senders/email.ts @@ -0,0 +1,167 @@ +import nodemailer from "nodemailer"; +import type { Transporter } from "nodemailer"; + +import type { + EmailChannelConfig, + NotificationPayload, +} from "@/lib/notifications/types"; +import type { SendOutcome } from "@/lib/notifications/retry-policy"; +import { getEvent } from "@/lib/logging/context"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { loadEmailConfig } from "@/lib/notifications/senders/email-config"; +import { plainPushText } from "@/lib/notifications/strip-emoji"; + +/** + * SMTP / email sender (v1.17.1). + * + * The SMTP transport is OPERATOR-configured via `SMTP_*` env (see + * `email-config.ts`); the per-user config carries only the recipient address. + * When SMTP is unconfigured the send returns a soft `skipped` outcome and the + * dispatcher never surfaces the channel (the Settings card is hidden too). + * + * Body is PLAIN TEXT — no markdown library (hard rule). Routine reminders run + * through `plainPushText` so the colour-coded phase emoji don't leak into the + * subject/body; SYSTEM_ALERTs keep their severity glyphs. + * + * Transport errors map to a transient `SendOutcome` so the dispatcher's + * backoff/auto-disable machinery absorbs a flapping mail server exactly like + * the HTTP channels. nodemailer surfaces `err.responseCode` for SMTP-level + * rejections; a 5xx permanent rejection (bad recipient, relay denied) is a + * hard reject so the channel auto-disables rather than retrying forever. + */ + +// Cache one transporter per process keyed on the resolved transport config. +// Rebuilding a transporter per send would re-establish the connection pool on +// every dispatch; nodemailer transports are safe to reuse. +let cachedTransporter: Transporter | undefined; +let cachedTransportKey: string | undefined; + +function getTransporter(): { transporter: Transporter; from: string } | null { + const config = loadEmailConfig(); + if (!config) return null; + + const key = JSON.stringify({ + host: config.host, + port: config.port, + secure: config.secure, + hasAuth: Boolean(config.auth), + }); + if (cachedTransporter && cachedTransportKey === key) { + return { transporter: cachedTransporter, from: config.from }; + } + + cachedTransporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + ...(config.auth ? { auth: config.auth } : {}), + }); + cachedTransportKey = key; + return { transporter: cachedTransporter, from: config.from }; +} + +/** Test-only: drop the cached transporter so a re-configured env takes effect. */ +export function resetEmailTransporterForTesting(): void { + cachedTransporter = undefined; + cachedTransportKey = undefined; +} + +function classifySmtpError(err: unknown): { + hardReject: boolean; + reason: string; + message: string; +} { + const message = err instanceof Error ? err.message : "smtp_send_failed"; + // nodemailer attaches `responseCode` (the SMTP reply code) on protocol-level + // rejections. 5xx = permanent (bad mailbox, relay denied) → hard reject so + // the channel auto-disables. 4xx / connection errors = transient → retry. + const responseCode = + typeof err === "object" && err !== null && "responseCode" in err + ? (err as { responseCode?: number }).responseCode + : undefined; + if (typeof responseCode === "number" && responseCode >= 500) { + return { hardReject: true, reason: "email_smtp_5xx", message }; + } + return { hardReject: false, reason: "email_smtp_error", message }; +} + +export async function sendViaEmail( + config: EmailChannelConfig, + payload: NotificationPayload, +): Promise { + const start = performance.now(); + + const transport = getTransporter(); + if (!transport) { + // Operator hasn't configured SMTP — soft skip, no channel burn. + recordPushAttempt({ + userId: payload.userId, + channel: "EMAIL", + eventType: payload.eventType, + result: "skipped", + reason: "email_not_configured", + }); + return { ok: false, hardReject: false, reason: "email_not_configured" }; + } + + if (!config.recipient) { + recordPushAttempt({ + userId: payload.userId, + channel: "EMAIL", + eventType: payload.eventType, + result: "skipped", + reason: "email_no_recipient", + }); + return { ok: false, hardReject: false, reason: "email_no_recipient" }; + } + + // Plain text only — strip HTML + decorative emoji on routine reminders. + const subject = plainPushText(payload.title, payload.eventType); + const text = plainPushText( + payload.message.replace(/<[^>]*>/g, ""), + payload.eventType, + ); + + try { + await transport.transporter.sendMail({ + from: transport.from, + to: config.recipient, + subject, + text, + }); + + getEvent()?.addExternalCall({ + service: "email", + method: "sendMail", + duration_ms: Math.round(performance.now() - start), + }); + recordPushAttempt({ + userId: payload.userId, + channel: "EMAIL", + eventType: payload.eventType, + result: "ok", + }); + return { ok: true }; + } catch (err) { + const classified = classifySmtpError(err); + getEvent()?.addExternalCall({ + service: "email", + method: "sendMail", + duration_ms: Math.round(performance.now() - start), + error: classified.message, + }); + recordPushAttempt({ + userId: payload.userId, + channel: "EMAIL", + eventType: payload.eventType, + result: "error", + reason: classified.reason, + }); + return { + ok: false, + hardReject: classified.hardReject, + reason: classified.reason, + message: classified.message, + }; + } +} From 17dd10c3b99b8f0a33c2cc184ec9c8aa25cd3b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:46:21 +0200 Subject: [PATCH 4/5] feat(admin): operator-wide notification health panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the webhook + email cards on the notifications settings page, and add an operator-wide delivery-health view the per-self diagnostic could not give. A single groupBy(channel, result) over the trailing 24h of push_attempts lets the operator answer "are anyone's pushes failing?" across all users without DB shell, and a count of auto-disabled channels (enabled=false with an automated disabledReason) flags a flapping upstream that silently disabled a channel after five failures. The aggregate is anonymous — it counts channel x result, never names a user. Admin-only by construction (cookie auth, never Bearer); the window is clamped 1..168h. The panel rides the existing admin Reminders section. --- .../health/__tests__/route.test.ts | 155 ++++++++++++++++++ .../api/admin/notifications/health/route.ts | 103 ++++++++++++ src/components/admin/reminders-section.tsx | 110 ++++++++++++- .../settings/notifications-section.tsx | 12 ++ src/lib/query-keys/admin.ts | 3 + 5 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 src/app/api/admin/notifications/health/__tests__/route.test.ts create mode 100644 src/app/api/admin/notifications/health/route.ts diff --git a/src/app/api/admin/notifications/health/__tests__/route.test.ts b/src/app/api/admin/notifications/health/__tests__/route.test.ts new file mode 100644 index 000000000..319776185 --- /dev/null +++ b/src/app/api/admin/notifications/health/__tests__/route.test.ts @@ -0,0 +1,155 @@ +/** + * v1.17.1 — coverage for the operator-wide notification-health admin route. + * + * Pins: + * * 401 unauthenticated / 403 non-admin (requireAdmin is cookie-only), + * * the groupBy(channel, result) aggregate folds into per-channel + * ok/error/skipped/total rows, + * * auto-disabled channels (enabled=false + disabledReason not null) are + * surfaced with a count, + * * the window is clamped to 1..168 hours (default 24). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + pushAttempt: { groupBy: vi.fn() }, + notificationChannel: { groupBy: vi.fn() }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { ...actual, requireAdmin: vi.fn() }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), + eventStorage: { run: (_e: unknown, fn: () => Promise) => fn() }, +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { NextRequest } from "next/server"; + +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "admin-1", username: "admin", role: "ADMIN" }, +} as never; + +function req(hours?: string): NextRequest { + const url = hours + ? `http://localhost/api/admin/notifications/health?hours=${hours}` + : "http://localhost/api/admin/notifications/health"; + return new NextRequest(url); +} + +interface HealthEnvelope { + data: { + windowHours: number; + channels: Array<{ + channel: string; + ok: number; + error: number; + skipped: number; + total: number; + }>; + autoDisabledChannels: Array<{ type: string; count: number }>; + }; + error: string | null; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([] as never); +}); + +describe("GET /api/admin/notifications/health — auth gates", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + const res = await GET(req()); + expect(res.status).toBe(401); + }); + + it("returns 403 for a non-admin", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(403, "Admin access required"), + ); + const res = await GET(req()); + expect(res.status).toBe(403); + }); +}); + +describe("GET /api/admin/notifications/health — aggregate", () => { + beforeEach(() => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + }); + + it("folds groupBy rows into per-channel ok/error/skipped/total", async () => { + vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([ + { channel: "NTFY", result: "ok", _count: { _all: 40 } }, + { channel: "NTFY", result: "error", _count: { _all: 12 } }, + { channel: "EMAIL", result: "ok", _count: { _all: 5 } }, + { channel: "EMAIL", result: "skipped", _count: { _all: 2 } }, + ] as never); + + const res = await GET(req()); + expect(res.status).toBe(200); + const body = (await res.json()) as HealthEnvelope; + + const ntfy = body.data.channels.find((c) => c.channel === "NTFY"); + expect(ntfy).toEqual({ + channel: "NTFY", + ok: 40, + error: 12, + skipped: 0, + total: 52, + }); + const email = body.data.channels.find((c) => c.channel === "EMAIL"); + expect(email).toEqual({ + channel: "EMAIL", + ok: 5, + error: 0, + skipped: 2, + total: 7, + }); + // Sorted alphabetically. + expect(body.data.channels.map((c) => c.channel)).toEqual(["EMAIL", "NTFY"]); + }); + + it("surfaces auto-disabled channels with a count", async () => { + vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([ + { type: "WEBHOOK", _count: { _all: 3 } }, + ] as never); + + const res = await GET(req()); + const body = (await res.json()) as HealthEnvelope; + expect(body.data.autoDisabledChannels).toEqual([ + { type: "WEBHOOK", count: 3 }, + ]); + }); + + it("defaults the window to 24h and clamps out-of-range values", async () => { + let res = await GET(req("999")); + let body = (await res.json()) as HealthEnvelope; + expect(body.data.windowHours).toBe(24); + + res = await GET(req("0")); + body = (await res.json()) as HealthEnvelope; + expect(body.data.windowHours).toBe(24); + + res = await GET(req("48")); + body = (await res.json()) as HealthEnvelope; + expect(body.data.windowHours).toBe(48); + }); +}); diff --git a/src/app/api/admin/notifications/health/route.ts b/src/app/api/admin/notifications/health/route.ts new file mode 100644 index 000000000..f39ff0909 --- /dev/null +++ b/src/app/api/admin/notifications/health/route.ts @@ -0,0 +1,103 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; + +export const dynamic = "force-dynamic"; + +/** + * v1.17.1 — operator-wide notification delivery health. + * + * The per-self diagnostic (`/api/admin/notifications/diagnostic`) only shows + * the calling admin's own account. On a multi-user instance the operator + * cannot answer "are anyone's pushes failing?" without DB shell. This route + * does a fleet-wide `groupBy(channel, result)` over the trailing window of the + * `push_attempts` ledger, plus a list of channels currently auto-disabled + * (across ALL users), so the operator can spot a flapping upstream that + * silently disabled a channel after five failures. + * + * Admin-only (cookie auth, never Bearer) by construction via `requireAdmin()`. + * The aggregate is anonymous: it counts channel×result, it never names a user. + */ + +interface ChannelHealthRow { + channel: string; + ok: number; + error: number; + skipped: number; + total: number; +} + +interface DisabledChannelRow { + type: string; + count: number; +} + +interface HealthPayload { + windowHours: number; + since: string; + channels: ChannelHealthRow[]; + autoDisabledChannels: DisabledChannelRow[]; +} + +export const GET = apiHandler(async (request) => { + await requireAdmin(); + annotate({ action: { name: "admin.notifications.health" } }); + + // Window is operator-tunable via ?hours=, clamped 1..168 (7 days). The + // `(channel, result, created_at)`-friendly index on push_attempts keeps the + // groupBy bounded regardless of instance lifetime volume. + const url = new URL(request.url); + const hoursRaw = Number(url.searchParams.get("hours")); + const windowHours = + Number.isFinite(hoursRaw) && hoursRaw >= 1 && hoursRaw <= 168 + ? Math.floor(hoursRaw) + : 24; + const since = new Date(Date.now() - windowHours * 60 * 60 * 1000); + + const [grouped, disabled] = await Promise.all([ + prisma.pushAttempt.groupBy({ + by: ["channel", "result"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + // Channels disabled with an automated reason. A manual disable leaves + // `disabledReason` NULL (see schema comment), so a non-null reason on a + // disabled channel is the auto-disable signal we surface to the operator. + prisma.notificationChannel.groupBy({ + by: ["type"], + where: { enabled: false, disabledReason: { not: null } }, + _count: { _all: true }, + }), + ]); + + const byChannel = new Map(); + for (const row of grouped) { + const existing = + byChannel.get(row.channel) ?? + ({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow); + const count = row._count._all; + if (row.result === "ok") existing.ok += count; + else if (row.result === "error") existing.error += count; + else if (row.result === "skipped") existing.skipped += count; + existing.total += count; + byChannel.set(row.channel, existing); + } + + const channels = Array.from(byChannel.values()).sort((a, b) => + a.channel.localeCompare(b.channel), + ); + + const autoDisabledChannels: DisabledChannelRow[] = disabled + .map((d) => ({ type: d.type, count: d._count._all })) + .sort((a, b) => a.type.localeCompare(b.type)); + + const payload: HealthPayload = { + windowHours, + since: since.toISOString(), + channels, + autoDisabledChannels, + }; + + return apiSuccess(payload); +}); diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx index 3f4449a43..3f60454fd 100644 --- a/src/components/admin/reminders-section.tsx +++ b/src/components/admin/reminders-section.tsx @@ -1,8 +1,15 @@ "use client"; import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; -import { Activity, Bell, CheckCircle2, Loader2, XCircle } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Activity, + Bell, + CheckCircle2, + HeartPulse, + Loader2, + XCircle, +} from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -10,8 +17,102 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { useAdminSettings, useUpdateSettings } from "./_shared"; -import { apiPost } from "@/lib/api/api-fetch"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; + +interface NotificationHealth { + windowHours: number; + since: string; + channels: Array<{ + channel: string; + ok: number; + error: number; + skipped: number; + total: number; + }>; + autoDisabledChannels: Array<{ type: string; count: number }>; +} + +/** + * v1.17.1 — operator-wide notification delivery-health panel. A single + * `groupBy(channel, result)` over the trailing 24h of `push_attempts` plus the + * count of auto-disabled channels across all users. Lets the operator answer + * "are anyone's pushes failing?" without DB shell. + */ +function NotificationHealthPanel() { + const { t } = useTranslations(); + const windowHours = 24; + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.adminNotificationHealth(windowHours), + queryFn: async () => + apiGet( + `/api/admin/notifications/health?hours=${windowHours}`, + ), + }); + + return ( +
+ + +
+ {isLoading && ( + + )} + + {data && data.channels.length === 0 && ( +

+ {t("admin.notificationHealth.empty")} +

+ )} + + {data && data.channels.length > 0 && ( +
+ {data.channels.map((c) => ( +
+ {c.channel} + + + {t("admin.notificationHealth.ok")}: {c.ok} + + + {t("admin.notificationHealth.error")}: {c.error} + + + {t("admin.notificationHealth.skipped")}: {c.skipped} + + +
+ ))} +
+ )} + + {data && data.autoDisabledChannels.length > 0 && ( +
+

+ {t("admin.notificationHealth.autoDisabled")} +

+
+ {data.autoDisabledChannels.map((d) => ( + + {d.type} ({d.count}) + + ))} +
+
+ )} +
+
+ ); +} export function RemindersSection() { const { t } = useTranslations(); @@ -92,6 +193,7 @@ export function RemindersSection() { }); return ( + <>
{/* v1.4.22 D / F-58 — icon swap so the section header matches the admin-shell nav tile (Bell). Clock now lives on the @@ -295,5 +397,7 @@ export function RemindersSection() {
)} + + ); } diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index e7172f7c5..600569109 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -14,6 +14,8 @@ import { NotificationStatusCard } from "@/components/settings/notification-statu import { NtfyCard } from "@/components/settings/ntfy-card"; import { TelegramCard } from "@/components/settings/telegram-card"; import { WebPushCard } from "@/components/settings/web-push-card"; +import { WebhookCard } from "@/components/settings/webhook-card"; +import { EmailCard } from "@/components/settings/email-card"; import { apiGet } from "@/lib/api/api-fetch"; interface GlobalServiceAvailability { @@ -105,6 +107,16 @@ export function NotificationsSection() { )} + {/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack / + Matrix / Home Assistant in one channel). */} +
+ +
+ {/* v1.17.1 — SMTP / email. The card hides itself when the operator + hasn't configured SMTP_* env, so it never shows a dead toggle. */} +
+ +
diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 0403206e1..5f604108e 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -63,6 +63,9 @@ export const adminKeys = { adminSettings: () => ["admin", "settings"] as const, adminStatus: () => ["admin", "status"] as const, + /** v1.17.1 — operator-wide notification delivery-health panel. */ + adminNotificationHealth: (hours: number) => + ["admin", "notification-health", hours] as const, adminUsers: () => ["admin", "users"] as const, adminTokens: () => ["admin", "tokens"] as const, adminAuditLog: (filter: unknown) => ["admin", "audit-log", filter] as const, From c04bdc8f4e06a8012cf921b72436e4e8de150dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:46:28 +0200 Subject: [PATCH 5/5] feat(notifications): document SMTP env and translate channel strings Add the webhook + email Settings copy and the admin notification-health panel labels across all six locales. Add the SMTP_* operator vars to the env manifest (all-or-none: HOST + PORT + FROM enable the channel, USER/PASS optional), to .env.production.example, and to the docker-compose environment whitelist so an operator's .env values actually reach the container. --- .env.production.example | 18 ++++++++++++++++++ docker-compose.yml | 12 ++++++++++++ messages/de.json | 20 ++++++++++++++++++++ messages/en.json | 20 ++++++++++++++++++++ messages/es.json | 20 ++++++++++++++++++++ messages/fr.json | 20 ++++++++++++++++++++ messages/it.json | 20 ++++++++++++++++++++ messages/pl.json | 20 ++++++++++++++++++++ scripts/env-manifest.json | 20 ++++++++++++++++++++ 9 files changed, 170 insertions(+) diff --git a/.env.production.example b/.env.production.example index d53e7acd1..258db482e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -84,6 +84,24 @@ API_TOKEN_HMAC_KEY="" # APNS_KEY_FILE="" +# ----------------------------------------------------------------------------- +# SMTP -- email notification channel (optional, all-or-none) +# ----------------------------------------------------------------------------- +# Operator SMTP transport for the email channel. SMTP_HOST + SMTP_PORT + +# SMTP_FROM together enable it; SMTP_USER/SMTP_PASS are optional (omit for an +# unauthenticated relay). SMTP_SECURE=true uses implicit TLS (port 465); +# unset/false uses STARTTLS (port 587). Per-user recipient address is set in +# Settings -> Notifications, not here. Missing the core trio disables the +# channel silently. Listed in docker-compose.yml's environment whitelist so +# these reach the container. +# SMTP_HOST="" +# SMTP_PORT="587" +# SMTP_FROM="HealthLog " +# SMTP_USER="" +# SMTP_PASS="" +# SMTP_SECURE="false" + + # ----------------------------------------------------------------------------- # Deploy webhook -- Coolify -> HealthLog auto-deploy feedback # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 57e84f8b5..1c6b5798a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,6 +107,18 @@ services: # container (the `environment:` block is a whitelist). See # docs/ops/tls-cert-pin.md. TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}" + # v1.17.1 — SMTP transport for the email notification channel (optional, + # all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE + # toggles implicit TLS). Listed here so operator values in .env reach the + # container (the `environment:` block is a whitelist — vars not listed + # never propagate from .env, even with `${VAR}` substitution at + # compose-up). Per-user recipient is stored in the DB, not here. + SMTP_HOST: "${SMTP_HOST:-}" + SMTP_PORT: "${SMTP_PORT:-}" + SMTP_FROM: "${SMTP_FROM:-}" + SMTP_USER: "${SMTP_USER:-}" + SMTP_PASS: "${SMTP_PASS:-}" + SMTP_SECURE: "${SMTP_SECURE:-}" depends_on: db: condition: service_healthy diff --git a/messages/de.json b/messages/de.json index 504fbedc1..57f127fae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4133,6 +4133,17 @@ "ntfyTopic": "Topic", "ntfyAuthToken": "Auth-Token (optional)", "ntfyAuthTokenHint": "Nur nötig bei privaten Topics mit Zugangsschutz.", + "webhook": "Webhook", + "webhookDescription": "Benachrichtigungen per POST an eine beliebige URL — Gotify, Discord, Slack, Matrix oder Home Assistant.", + "webhookEnable": "Webhook aktivieren", + "webhookUrl": "Webhook-URL", + "webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.", + "webhookHeaderName": "Header-Name (optional)", + "webhookHeaderValue": "Header-Wert (optional)", + "email": "E-Mail", + "emailDescription": "Warnungen und Erinnerungen per E-Mail empfangen.", + "emailEnable": "E-Mail aktivieren", + "emailRecipient": "Empfängeradresse", "webPush": "Browser-Push", "webPushDescription": "Erhalte Benachrichtigungen direkt im Browser, auch wenn HealthLog nicht geöffnet ist.", "webPushNotSupported": "Dein Browser unterstützt keine Push-Benachrichtigungen.", @@ -5011,6 +5022,15 @@ "authTokenRevoke": "Token widerrufen", "medicationReminders": "Medikamentenerinnerungen", "medicationRemindersDescription": "Schwellenwerte für die Farbübergänge der Erinnerungs-Badges auf den Medikamentenkarten.", + "notificationHealth": { + "title": "Zustellungsstatus", + "description": "Zustellungsergebnisse aller Nutzer der letzten 24 Stunden.", + "empty": "In diesem Zeitraum wurden keine Benachrichtigungen gesendet.", + "ok": "OK", + "error": "Fehlgeschlagen", + "skipped": "Übersprungen", + "autoDisabled": "Automatisch deaktivierte Kanäle" + }, "reminderLateMinutes": "Spät-Schwellenwert (Minuten)", "reminderLateMinutesDescription": "Minuten nach Fenster-Ende bis der Badge von Gelb auf Orange wechselt.", "reminderMissedMinutes": "Verpasst-Schwellenwert (Minuten)", diff --git a/messages/en.json b/messages/en.json index 8477957ff..cc3e5d04d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4133,6 +4133,17 @@ "ntfyTopic": "Topic", "ntfyAuthToken": "Auth Token (optional)", "ntfyAuthTokenHint": "Only needed for private topics with access control.", + "webhook": "Webhook", + "webhookDescription": "POST notifications to any URL — Gotify, Discord, Slack, Matrix, or Home Assistant.", + "webhookEnable": "Enable webhook", + "webhookUrl": "Webhook URL", + "webhookUrlHint": "The full URL to POST to. Must be publicly reachable.", + "webhookHeaderName": "Header name (optional)", + "webhookHeaderValue": "Header value (optional)", + "email": "Email", + "emailDescription": "Receive alerts and reminders by email.", + "emailEnable": "Enable email", + "emailRecipient": "Recipient address", "webPush": "Browser Push", "webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.", "webPushNotSupported": "Your browser does not support push notifications.", @@ -5011,6 +5022,15 @@ "authTokenRevoke": "Token revoked", "medicationReminders": "Medication Reminders", "medicationRemindersDescription": "Thresholds for reminder badge color transitions on medication cards.", + "notificationHealth": { + "title": "Notification health", + "description": "Delivery results across all users over the last 24 hours.", + "empty": "No notifications sent in this window.", + "ok": "OK", + "error": "Failed", + "skipped": "Skipped", + "autoDisabled": "Auto-disabled channels" + }, "reminderLateMinutes": "Late threshold (minutes)", "reminderLateMinutesDescription": "Minutes after window end until the badge changes from yellow to orange.", "reminderMissedMinutes": "Missed threshold (minutes)", diff --git a/messages/es.json b/messages/es.json index 4ac12939a..aa0316c06 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4133,6 +4133,17 @@ "ntfyTopic": "Topic", "ntfyAuthToken": "Token de autenticación (opcional)", "ntfyAuthTokenHint": "Solo necesario para temas privados con control de acceso.", + "webhook": "Webhook", + "webhookDescription": "Envía notificaciones por POST a cualquier URL: Gotify, Discord, Slack, Matrix o Home Assistant.", + "webhookEnable": "Activar webhook", + "webhookUrl": "URL del webhook", + "webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.", + "webhookHeaderName": "Nombre de cabecera (opcional)", + "webhookHeaderValue": "Valor de cabecera (opcional)", + "email": "Correo electrónico", + "emailDescription": "Recibe alertas y recordatorios por correo electrónico.", + "emailEnable": "Activar correo electrónico", + "emailRecipient": "Dirección del destinatario", "webPush": "Push del navegador", "webPushDescription": "Recibe notificaciones directamente en tu navegador, incluso cuando HealthLog no está abierto.", "webPushNotSupported": "Tu navegador no admite notificaciones push.", @@ -5011,6 +5022,15 @@ "authTokenRevoke": "Token revocado", "medicationReminders": "Recordatorios de medicación", "medicationRemindersDescription": "Umbrales para los cambios de color de las insignias de recordatorio en las tarjetas de medicación.", + "notificationHealth": { + "title": "Estado de entrega", + "description": "Resultados de entrega de todos los usuarios en las últimas 24 horas.", + "empty": "No se enviaron notificaciones en este periodo.", + "ok": "OK", + "error": "Fallidas", + "skipped": "Omitidas", + "autoDisabled": "Canales desactivados automáticamente" + }, "reminderLateMinutes": "Umbral de retraso (minutos)", "reminderLateMinutesDescription": "Minutos tras el fin de la ventana hasta que la insignia cambia de amarillo a naranja.", "reminderMissedMinutes": "Umbral de omisión (minutos)", diff --git a/messages/fr.json b/messages/fr.json index a5d6fc975..9182df186 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4133,6 +4133,17 @@ "ntfyTopic": "Topic", "ntfyAuthToken": "Jeton d’authentification (facultatif)", "ntfyAuthTokenHint": "Nécessaire uniquement pour les sujets privés avec contrôle d’accès.", + "webhook": "Webhook", + "webhookDescription": "Envoyer les notifications par POST vers n’importe quelle URL — Gotify, Discord, Slack, Matrix ou Home Assistant.", + "webhookEnable": "Activer le webhook", + "webhookUrl": "URL du webhook", + "webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.", + "webhookHeaderName": "Nom d’en-tête (facultatif)", + "webhookHeaderValue": "Valeur d’en-tête (facultatif)", + "email": "E-mail", + "emailDescription": "Recevoir les alertes et les rappels par e-mail.", + "emailEnable": "Activer l’e-mail", + "emailRecipient": "Adresse du destinataire", "webPush": "Push navigateur", "webPushDescription": "Recevez des notifications directement dans votre navigateur, même lorsque HealthLog n’est pas ouvert.", "webPushNotSupported": "Votre navigateur ne prend pas en charge les notifications push.", @@ -5011,6 +5022,15 @@ "authTokenRevoke": "Jeton révoqué", "medicationReminders": "Rappels de médicaments", "medicationRemindersDescription": "Seuils des transitions de couleur des badges de rappel sur les fiches de médicaments.", + "notificationHealth": { + "title": "État de distribution", + "description": "Résultats de distribution de tous les utilisateurs sur les dernières 24 heures.", + "empty": "Aucune notification envoyée sur cette période.", + "ok": "OK", + "error": "Échecs", + "skipped": "Ignorées", + "autoDisabled": "Canaux désactivés automatiquement" + }, "reminderLateMinutes": "Seuil de retard (minutes)", "reminderLateMinutesDescription": "Minutes après la fin de la fenêtre avant que le badge passe du jaune à l'orange.", "reminderMissedMinutes": "Seuil d'oubli (minutes)", diff --git a/messages/it.json b/messages/it.json index 15cc4ed2e..7e3d685cb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4133,6 +4133,17 @@ "ntfyTopic": "Topic", "ntfyAuthToken": "Token di autenticazione (facoltativo)", "ntfyAuthTokenHint": "Necessario solo per topic privati con controllo degli accessi.", + "webhook": "Webhook", + "webhookDescription": "Invia le notifiche via POST a qualsiasi URL — Gotify, Discord, Slack, Matrix o Home Assistant.", + "webhookEnable": "Attiva webhook", + "webhookUrl": "URL del webhook", + "webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.", + "webhookHeaderName": "Nome intestazione (opzionale)", + "webhookHeaderValue": "Valore intestazione (opzionale)", + "email": "Email", + "emailDescription": "Ricevi avvisi e promemoria via email.", + "emailEnable": "Attiva email", + "emailRecipient": "Indirizzo del destinatario", "webPush": "Push del browser", "webPushDescription": "Ricevi notifiche direttamente nel browser, anche quando HealthLog non è aperto.", "webPushNotSupported": "Il tuo browser non supporta le notifiche push.", @@ -5011,6 +5022,15 @@ "authTokenRevoke": "Token revocato", "medicationReminders": "Promemoria dei farmaci", "medicationRemindersDescription": "Soglie per i cambi di colore dei badge di promemoria sulle schede dei farmaci.", + "notificationHealth": { + "title": "Stato di consegna", + "description": "Risultati di consegna di tutti gli utenti nelle ultime 24 ore.", + "empty": "Nessuna notifica inviata in questo periodo.", + "ok": "OK", + "error": "Non riuscite", + "skipped": "Saltate", + "autoDisabled": "Canali disattivati automaticamente" + }, "reminderLateMinutes": "Soglia di ritardo (minuti)", "reminderLateMinutesDescription": "Minuti dopo la fine della finestra prima che il badge passi da giallo ad arancione.", "reminderMissedMinutes": "Soglia di mancata assunzione (minuti)", diff --git a/messages/pl.json b/messages/pl.json index 5b865a424..72e762043 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4133,6 +4133,17 @@ "ntfyTopic": "Topic", "ntfyAuthToken": "Token uwierzytelniania (opcjonalny)", "ntfyAuthTokenHint": "Wymagany tylko dla prywatnych tematów z kontrolą dostępu.", + "webhook": "Webhook", + "webhookDescription": "Wysyłaj powiadomienia metodą POST na dowolny URL — Gotify, Discord, Slack, Matrix lub Home Assistant.", + "webhookEnable": "Włącz webhook", + "webhookUrl": "Adres URL webhooka", + "webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.", + "webhookHeaderName": "Nazwa nagłówka (opcjonalnie)", + "webhookHeaderValue": "Wartość nagłówka (opcjonalnie)", + "email": "E-mail", + "emailDescription": "Otrzymuj alerty i przypomnienia e-mailem.", + "emailEnable": "Włącz e-mail", + "emailRecipient": "Adres odbiorcy", "webPush": "Push przeglądarki", "webPushDescription": "Otrzymuj powiadomienia bezpośrednio w przeglądarce, nawet gdy HealthLog nie jest otwarty.", "webPushNotSupported": "Twoja przeglądarka nie obsługuje powiadomień push.", @@ -5011,6 +5022,15 @@ "authTokenRevoke": "Token unieważniony", "medicationReminders": "Przypomnienia o lekach", "medicationRemindersDescription": "Progi zmian koloru oznaczeń przypomnień na kartach leków.", + "notificationHealth": { + "title": "Stan dostarczania", + "description": "Wyniki dostarczania wszystkich użytkowników z ostatnich 24 godzin.", + "empty": "W tym okresie nie wysłano żadnych powiadomień.", + "ok": "OK", + "error": "Nieudane", + "skipped": "Pominięte", + "autoDisabled": "Automatycznie wyłączone kanały" + }, "reminderLateMinutes": "Próg opóźnienia (minuty)", "reminderLateMinutesDescription": "Minuty po końcu okna, zanim oznaczenie zmieni kolor z żółtego na pomarańczowy.", "reminderMissedMinutes": "Próg pominięcia (minuty)", diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 6b3d72f90..51f68af58 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -137,6 +137,26 @@ } ] }, + { + "name": "SMTP (email channel)", + "description": "Operator SMTP transport for the email notification channel (v1.17.1). All of SMTP_HOST, SMTP_PORT, SMTP_FROM together enable the channel; SMTP_USER/SMTP_PASS are optional (unauthenticated relay). Missing any of the three core vars disables email silently — the channel is never offered and the Settings card hides itself.", + "required": false, + "allOrNone": true, + "variables": [ + { + "name": "SMTP_HOST", + "purpose": "SMTP server hostname, e.g. smtp.example.com." + }, + { + "name": "SMTP_PORT", + "purpose": "SMTP port. 587 for STARTTLS (SMTP_SECURE unset/false), 465 for implicit TLS (SMTP_SECURE=true)." + }, + { + "name": "SMTP_FROM", + "purpose": "From address for outbound mail, e.g. \"HealthLog \"." + } + ] + }, { "name": "Off-host backups", "description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.",