mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
merge(notifications): generic webhook + SMTP email channels and operator delivery health
This commit is contained in:
commit
45ccdcb305
35 files changed
+2064
-13
No files matched your search
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+25
-6
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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