mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user