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")