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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:44:26 +02:00
1 parent d1055b4047
commit aec41e154f
7 files changed
+146 -4

No files matched your search

@@ -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;
+40 -1
View File
@@ -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;
}
+15 -1
View File
@@ -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.
@@ -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;
+48 -1
View File
@@ -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<ChannelType, string> = {
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 <token>` 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 <token>"). 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 {
+4
View File
@@ -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,
+30
View File
@@ -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")