feat(notifications): add ntfy as a first-class notification channel

Adds ntfy.sh (and self-hosted instances) as a new push notification
channel with full parity to the existing webhook channel.

- Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig,
  resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/
  Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per
  event), SSRF guard via existing checkSsrf + createPinnedDispatcher
- notificationPreferencesService: ntfy added to NotifChannel union,
  IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels,
  ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface
- notificationService: per-user ntfy dispatch after webhook block;
  admin-scoped ntfy via getAdminGlobalPref for version_available events
- Routes: POST /api/notifications/test-ntfy with saved-token fallback
- authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS,
  masked + encrypted on read/write
- settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS
- Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in
  NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method
- i18n: full English strings; English placeholders in 14 other locales
- Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests,
  MSW handler for test-ntfy endpoint
This commit is contained in:
jubnl
2026-04-15 13:59:25 +02:00
parent f349e567f8
commit bfe84b3016
30 changed files with 1241 additions and 52 deletions
@@ -7,15 +7,20 @@ import {
isSmtpConfigured,
ADMIN_SCOPED_EVENTS,
type NotifEventType,
type NotifChannel,
} from './notificationPreferencesService';
import {
getEventText,
sendEmail,
sendWebhook,
sendNtfy,
getUserEmail,
getUserLanguage,
getUserWebhookUrl,
getAdminWebhookUrl,
getUserNtfyConfig,
getAdminNtfyConfig,
resolveNtfyUrl,
getAppUrl,
} from './notifications';
import {
@@ -270,6 +275,19 @@ export async function send(payload: NotificationPayload): Promise<void> {
}
}
// ── Ntfy (per-user) — skip for admin-scoped events (handled globally below) ──
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('ntfy') && isEnabledForEvent(recipientId, event, 'ntfy' as NotifChannel)) {
const userNtfyCfg = getUserNtfyConfig(recipientId);
const adminNtfyCfg = getAdminNtfyConfig();
const ntfyUrl = resolveNtfyUrl(adminNtfyCfg, userNtfyCfg);
if (ntfyUrl) {
const lang = getUserLanguage(recipientId);
const { title, body } = getEventText(lang, event, params);
const token = userNtfyCfg?.token ?? adminNtfyCfg.token;
promises.push(sendNtfy(ntfyUrl, token, { event, title, body, link: fullLink }));
}
}
const results = await Promise.allSettled(promises);
for (const result of results) {
if (result.status === 'rejected') {
@@ -288,4 +306,16 @@ export async function send(payload: NotificationPayload): Promise<void> {
});
}
}
// ── Admin ntfy (scope: admin) — global, respects global pref ─────────
if (scope === 'admin' && getAdminGlobalPref(event, 'ntfy')) {
const adminNtfyCfg = getAdminNtfyConfig();
const adminNtfyUrl = resolveNtfyUrl(adminNtfyCfg, null);
if (adminNtfyUrl) {
const { title, body } = getEventText('en', event, params);
await sendNtfy(adminNtfyUrl, adminNtfyCfg.token, { event, title, body, link: fullLink }).catch((err: unknown) => {
logError(`notificationService.send admin ntfy failed event=${event}: ${err instanceof Error ? err.message : err}`);
});
}
}
}