merge(notifications): generic webhook + SMTP email channels and operator delivery health

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:47:16 +02:00
commit 45ccdcb305
35 files changed
+2064 -13

No files matched your search

+18
View File
@@ -84,6 +84,24 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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 <noreply@example.com>"
# SMTP_USER=""
# SMTP_PASS=""
# SMTP_SECURE="false"
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
+12
View File
@@ -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
+20
View File
@@ -4174,6 +4174,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.",
@@ -5052,6 +5063,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)",
+20
View File
@@ -4174,6 +4174,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.",
@@ -5052,6 +5063,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)",
+20
View File
@@ -4174,6 +4174,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.",
@@ -5052,6 +5063,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)",
+20
View File
@@ -4174,6 +4174,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.",
@@ -5052,6 +5063,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)",
+20
View File
@@ -4174,6 +4174,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.",
@@ -5052,6 +5063,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)",
+20
View File
@@ -4174,6 +4174,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.",
@@ -5052,6 +5063,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)",
+2
View File
@@ -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",
+25 -6
View File
@@ -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:
+20
View File
@@ -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 <noreply@example.com>\"."
}
]
},
{
"name": "Off-host backups",
"description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.",
@@ -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;
@@ -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<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return { ...actual, requireAdmin: vi.fn() };
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: { run: <T>(_e: unknown, fn: () => Promise<T>) => 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);
});
});
@@ -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<string, ChannelHealthRow>();
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);
});
+82
View File
@@ -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 });
});
+54
View File
@@ -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 });
});
+106
View File
@@ -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 });
});
@@ -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 });
});
+107 -3
View File
@@ -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<NotificationHealth>(
`/api/admin/notifications/health?hours=${windowHours}`,
),
});
return (
<div className="bg-card border-border mt-6 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={HeartPulse}
title={t("admin.notificationHealth.title")}
description={t("admin.notificationHealth.description")}
/>
<div className="mt-4 pl-7">
{isLoading && (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{data && data.channels.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("admin.notificationHealth.empty")}
</p>
)}
{data && data.channels.length > 0 && (
<div className="space-y-2">
{data.channels.map((c) => (
<div
key={c.channel}
className="flex items-center justify-between text-sm"
>
<span className="font-medium">{c.channel}</span>
<span className="flex items-center gap-3 text-xs">
<span className="text-success dark:text-green-400">
{t("admin.notificationHealth.ok")}: {c.ok}
</span>
<span className="text-destructive dark:text-red-400">
{t("admin.notificationHealth.error")}: {c.error}
</span>
<span className="text-muted-foreground">
{t("admin.notificationHealth.skipped")}: {c.skipped}
</span>
</span>
</div>
))}
</div>
)}
{data && data.autoDisabledChannels.length > 0 && (
<div className="mt-4 space-y-1">
<h2 className="text-sm font-medium">
{t("admin.notificationHealth.autoDisabled")}
</h2>
<div className="flex flex-wrap gap-2">
{data.autoDisabledChannels.map((d) => (
<Badge key={d.type} variant="secondary">
{d.type} ({d.count})
</Badge>
))}
</div>
</div>
)}
</div>
</div>
);
}
export function RemindersSection() {
const { t } = useTranslations();
@@ -92,6 +193,7 @@ export function RemindersSection() {
});
return (
<>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* 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() {
</div>
)}
</div>
<NotificationHealthPanel />
</>
);
}
+142
View File
@@ -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<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsEmail(),
queryFn: async () => {
return apiGet<EmailSettings>("/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<string | null>(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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Mail}
title={t("settings.email")}
description={t("settings.emailDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-center justify-between">
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
<Switch
id="email-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="email-recipient">
{t("settings.emailRecipient")}
</Label>
<Input
id="email-recipient"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<TestConnectionButton
endpoint="/api/settings/email/test"
disabled={!settings?.enabled}
/>
<Button type="submit" disabled={save.isPending} className="min-h-11">
{save.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -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() {
<WebPushCard />
</div>
)}
{/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
Matrix / Home Assistant in one channel). */}
<div id="webhook" className="scroll-mt-28">
<WebhookCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.17.1 — SMTP / email. The card hides itself when the operator
hasn't configured SMTP_* env, so it never shows a dead toggle. */}
<div id="email" className="scroll-mt-28">
<EmailCard isAuthenticated={isAuthenticated} />
</div>
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
+174
View File
@@ -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<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsWebhook(),
queryFn: async () => {
return apiGet<WebhookSettings>("/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<string | null>(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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Webhook}
title={t("settings.webhook")}
description={t("settings.webhookDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-center justify-between">
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
<Switch
id="webhook-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="webhook-url">{t("settings.webhookUrl")}</Label>
<Input
id="webhook-url"
placeholder="https://gotify.example.com/message?token=..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.webhookUrlHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-name">
{t("settings.webhookHeaderName")}
</Label>
<Input
id="webhook-header-name"
placeholder="Authorization"
value={headerName}
onChange={(e) => setHeaderName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-value">
{t("settings.webhookHeaderValue")}
</Label>
<PasswordInput
id="webhook-header-value"
placeholder={
settings?.hasHeaderValue
? t("settings.withingsCredentialsSavedPlaceholder")
: t("common.optional")
}
value={headerValue}
onChange={(e) => setHeaderValue(e.target.value)}
/>
</div>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<TestConnectionButton
endpoint="/api/settings/webhook/test"
disabled={!settings?.enabled}
/>
<Button type="submit" disabled={save.isPending} className="min-h-11">
{save.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</div>
);
}
+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.
@@ -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" });
});
});
@@ -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 <noreply@example.com>",
auth: { user: "u", pass: "p" },
};
function payload(over?: Record<string, unknown>) {
return {
eventType: "SYSTEM_ALERT" as const,
userId: "user-1",
title: "Subject",
message: "<b>Body</b>",
...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" }),
);
});
});
@@ -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<string, unknown>) {
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<string, string> }).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<string, string> }).headers?.Authorization,
).toBeUndefined();
});
});
@@ -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;
}
+167
View File
@@ -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<SendOutcome> {
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,
};
}
}
@@ -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;
+122
View File
@@ -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 <token>` 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<SendOutcome> {
const start = performance.now();
try {
const headers: Record<string, string> = {
"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,
};
}
}
+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 {
+3
View File
@@ -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,
+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")