From ac966445084e1b236e3c1140a6a8d61b2d15cc91 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 8 Aug 2026 11:46:29 +0200 Subject: [PATCH] refactor(server): fold the notifications domain out of services/ Four files taken apart rather than moved, because they only worked as a knot. The mailer becomes its own leaf module. AuthService sends the password-reset mail while NotificationsModule imports AuthModule for the demo gate in NotificationsMcp - a mailer inside the notifications domain would have closed a hard cycle that only a forwardRef could open again. Webhook and ntfy stay with notifications; auth has no business importing them. The channel registry stays a module singleton and does NOT become a provider. PluginRuntimeService pushes its channel getter in at onModuleInit, and notifications.bridge.ts builds its own NotificationsService for the scheduler crons and the memories senders. A provider would hand the bridge a second, empty registry - every plugin channel would then go quiet with no error anywhere. CHOVR-015 drives that seam end to end: a channel published through the runtime, delivered through the bridge. Nothing pinned it before; the existing cases all set the source themselves. The built-ins no longer self-register by side-effect import. They used to exist only because notificationPreferencesService happened to `import './notifications/builtins'`; removing that line would have silenced email, webhook and ntfy without a failure. They are built from the injected transports and registered from NotificationsService's constructor, so both instances have them. No cached nodemailer transport: MailerService.createTransport re-reads the SMTP config and skipTlsVerify per send, exactly as the three inline call sites did, so an admin changing them does not have to restart. Preferences become a provider over DatabaseService. Eight places built NotificationsService by hand, so a tests/helpers/notifications.ts factory keeps the next constructor parameter a one-file diff instead of an eight-file one. --- server/src/nest/README.md | 27 +- server/src/nest/admin/admin.bridge.ts | 11 +- server/src/nest/admin/admin.service.ts | 7 +- server/src/nest/auth/auth.bridge.ts | 2 + server/src/nest/auth/auth.module.ts | 7 +- server/src/nest/auth/auth.service.ts | 5 +- .../notifications/channel-registry.ts} | 19 +- .../nest/notifications/channels/builtins.ts | 120 ++++ .../nest/notifications/mailer/email-html.ts | 104 ++++ .../notifications/mailer/mailer.module.ts | 15 + .../notifications/mailer/mailer.service.ts | 185 ++++++ .../notification-preferences.service.ts | 310 ++++++++++ .../notifications/notifications.bridge.ts | 21 +- .../notifications/notifications.module.ts | 17 +- .../notifications/notifications.service.ts | 85 ++- .../notifications/transports/ntfy.service.ts | 162 ++++++ .../transports/webhook.service.ts | 111 ++++ .../nest/plugins/plugin-runtime.service.ts | 3 +- .../notificationPreferencesService.ts | 309 ---------- server/src/services/notifications.ts | 546 ------------------ server/src/services/notifications/builtins.ts | 118 ---- server/tests/e2e/admin.e2e.test.ts | 10 +- server/tests/e2e/auth.e2e.test.ts | 9 +- server/tests/e2e/notifications.e2e.test.ts | 32 +- server/tests/helpers/mcp-test-controllers.ts | 3 +- server/tests/helpers/notifications.ts | 31 + server/tests/helpers/plugin-host.ts | 3 +- .../tests/integration/notifications.test.ts | 19 +- server/tests/unit/nest/admin.service.test.ts | 4 +- .../nest/admin.version-notification.test.ts | 4 +- server/tests/unit/nest/auth.service.test.ts | 7 +- .../channel-override.test.ts} | 66 ++- .../channel-registry.test.ts} | 18 +- .../notification-preferences.service.test.ts} | 33 +- .../notification-transports.test.ts} | 13 +- .../nest/notifications.inapp-prefs.test.ts | 3 +- .../unit/nest/notifications.service.test.ts | 5 +- server/tests/unit/nest/oidc.service.test.ts | 9 +- .../tests/unit/nest/passkey.service.test.ts | 10 +- 39 files changed, 1368 insertions(+), 1095 deletions(-) rename server/src/{services/notifications/channelRegistry.ts => nest/notifications/channel-registry.ts} (87%) create mode 100644 server/src/nest/notifications/channels/builtins.ts create mode 100644 server/src/nest/notifications/mailer/email-html.ts create mode 100644 server/src/nest/notifications/mailer/mailer.module.ts create mode 100644 server/src/nest/notifications/mailer/mailer.service.ts create mode 100644 server/src/nest/notifications/notification-preferences.service.ts create mode 100644 server/src/nest/notifications/transports/ntfy.service.ts create mode 100644 server/src/nest/notifications/transports/webhook.service.ts delete mode 100644 server/src/services/notificationPreferencesService.ts delete mode 100644 server/src/services/notifications.ts delete mode 100644 server/src/services/notifications/builtins.ts create mode 100644 server/tests/helpers/notifications.ts rename server/tests/unit/{services/channelOverride.test.ts => nest/channel-override.test.ts} (75%) rename server/tests/unit/{services/channelRegistry.test.ts => nest/channel-registry.test.ts} (80%) rename server/tests/unit/{services/notificationPreferencesService.test.ts => nest/notification-preferences.service.test.ts} (91%) rename server/tests/unit/{services/notifications.test.ts => nest/notification-transports.test.ts} (95%) diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 44943376f..08d8d8980 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -29,8 +29,9 @@ remains as the platform underneath `@nestjs/platform-express`. todo, packing, day-notes, trip-invite, assignments, share, settings, files, collab, vacay, reservations, day, permissions, audit, budget, trip, maps, transit, place, transit-itinerary, collections, atlas, auth, oidc, passkey, - notifications, admin, webauthn-config, user-cleanup, oauth, wiki — see the - migration recipe below. + notifications, admin, webauthn-config, user-cleanup, oauth, wiki, mailer, + notification transports, notification preferences — see the migration recipe + below. - **Foundation (BE-Phase 1, complete):** the eight stateless helpers moved to `common/`; every trip-access check routes through `DatabaseService` (`services/tripAccess.ts` and the dead `middleware/tripAccess.ts` are gone); @@ -41,9 +42,11 @@ remains as the platform underneath `@nestjs/platform-express`. addon guards are one `AddonGuard` + `@RequireAddon`; the demo-mode block is one condition. Still open: `TripAccessGuard`, default-deny, the MFA policy. -`src/services/` is down to five top-level files (journey, journeyShare, -notifications, notificationPreferences, backup) plus the `airtrail/`, -`memories/` and `notifications/` directories. Note that the trip-access and +`src/services/` is down to three top-level files (journey, journeyShare, backup) +plus the `airtrail/` and `memories/` directories. What is left each waits on an +open decision: memories on its Zod contracts, journey on memories, backup on the +plugins module split and the db-lifecycle question, airtrail on the credential +handling. Note that the trip-access and `canEdit` methods on the domain services are **not** dead weight waiting for a guard: their callers are overwhelmingly the `*.mcp.ts` tools, which never pass through an HTTP guard. In the five domains piloted for `TripAccessGuard`, 40 of @@ -119,6 +122,20 @@ singleton rather than a second instance. for free just because it is global: a provider injecting `RuntimeEnvService` fails to resolve and takes every suite importing that module down with it. Import it explicitly, the way `PermissionsModule` is handled. +- **A module-scoped registry that outside code writes into must stay a module, + not a provider.** The notification channel registry is the live example: + `PluginRuntimeService` pushes its channel getter in at `onModuleInit`, and + `notifications.bridge.ts` builds its own `NotificationsService` for the + scheduler crons. As a provider the bridge would get a second, empty registry + and every plugin channel would go quiet — with no error anywhere. + `CHOVR-015` is the regression test. Same reasoning as + `oauth/oauth.pending-codes.ts`. +- **Self-registration by side-effect import is a trap.** The built-in channels + used to register because `notificationPreferencesService` happened to `import + './notifications/builtins'`. Dropping that one line would have silenced email, + webhook and ntfy without a failure. They are built from injected transports and + registered in `NotificationsService`'s constructor now, so every path that can + dispatch has them. - A guard with a constructor dependency has to be a registered provider everywhere it is used. The three auth guards are dependency-free on purpose — 38 directories apply them and 21 do not import `AuthModule`, so giving them a diff --git a/server/src/nest/admin/admin.bridge.ts b/server/src/nest/admin/admin.bridge.ts index 9ca06c5ba..c5c64d761 100644 --- a/server/src/nest/admin/admin.bridge.ts +++ b/server/src/nest/admin/admin.bridge.ts @@ -11,6 +11,10 @@ import { WebauthnConfigService } from '../auth/webauthn-config.service'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { PackingService } from '../packing/packing.service'; import { PermissionsService } from '../permissions/permissions.service'; +import { MailerService } from '../notifications/mailer/mailer.service'; +import { NotificationPreferencesService } from '../notifications/notification-preferences.service'; +import { NtfyService } from '../notifications/transports/ntfy.service'; +import { WebhookService } from '../notifications/transports/webhook.service'; import { NotificationsService } from '../notifications/notifications.service'; import { AdminService } from './admin.service'; @@ -35,7 +39,9 @@ const realtime = new RealtimeService(); const permissions = new PermissionsService(dbs); const webauthn = new WebauthnConfigService(dbs); const userCleanup = new UserCleanupService(dbs); -const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup); +const mailer = new MailerService(dbs); +const notifPrefs = new NotificationPreferencesService(dbs, mailer); +const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup, mailer); const admin = new AdminService( dbs, new SettingsService(dbs), @@ -44,8 +50,9 @@ const admin = new AdminService( new PackingService(dbs, permissions, realtime), auth, permissions, - new NotificationsService(dbs, realtime), + new NotificationsService(dbs, realtime, mailer, new WebhookService(dbs), new NtfyService(dbs), notifPrefs), userCleanup, + notifPrefs, ); export function checkAndNotifyVersion(): Promise { diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 69b5fff92..fbc3579f8 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -21,7 +21,7 @@ import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../llm-parse/ import { getPhotoProviderConfig } from '../../services/memories/helpersService'; import { validatePassword } from '../common/passwordPolicy'; import { UserCleanupService } from '../auth/user-cleanup.service'; -import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService'; +import { NotificationPreferencesService } from '../notifications/notification-preferences.service'; import { DatabaseService } from '../database/database.service'; import { AddonsService } from '../addons/addons.service'; import { SettingsService } from '../settings/settings.service'; @@ -78,6 +78,7 @@ export class AdminService { private readonly permissions: PermissionsService, private readonly notifications: NotificationsService, private readonly userCleanup: UserCleanupService, + private readonly notifPrefs: NotificationPreferencesService, ) {} // ── User CRUD ────────────────────────────────────────────────────────────── @@ -851,6 +852,6 @@ export class AdminService { getAdminUserDefaults() { return this.settings.getAdminUserDefaults(); } setAdminUserDefaults(body: Record) { return this.settings.setAdminUserDefaults(body); } - getPreferencesMatrix(userId: number, role: string) { return getPreferencesMatrix(userId, role, 'admin'); } - setAdminPreferences(userId: number, body: unknown) { return setAdminPreferences(userId, body as Parameters[1]); } + getPreferencesMatrix(userId: number, role: string) { return this.notifPrefs.getPreferencesMatrix(userId, role, 'admin'); } + setAdminPreferences(userId: number, body: unknown) { return this.notifPrefs.setAdminPreferences(userId, body as Parameters[1]); } } diff --git a/server/src/nest/auth/auth.bridge.ts b/server/src/nest/auth/auth.bridge.ts index 574b4d436..f112f1fd4 100644 --- a/server/src/nest/auth/auth.bridge.ts +++ b/server/src/nest/auth/auth.bridge.ts @@ -4,6 +4,7 @@ import { PermissionsService } from '../permissions/permissions.service'; import { AtlasService } from '../atlas/atlas.service'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { AuthService } from './auth.service'; +import { MailerService } from '../notifications/mailer/mailer.service'; import { UserCleanupService } from './user-cleanup.service'; import { WebauthnConfigService } from './webauthn-config.service'; import { User } from '../../types'; @@ -42,6 +43,7 @@ const auth = new AuthService( new TripMembershipService(dbs), new WebauthnConfigService(dbs), new UserCleanupService(dbs), + new MailerService(dbs), ); export { stripUserForClient } from './auth.helpers'; diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index 10d472c5d..318fe4b4e 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -9,6 +9,7 @@ import { UserCleanupService } from './user-cleanup.service'; import { WebauthnConfigService } from './webauthn-config.service'; import { AppConfigModule } from '../app-config/app-config.module'; import { AuditModule } from '../audit/audit.module'; +import { MailerModule } from '../notifications/mailer/mailer.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { AtlasModule } from '../atlas/atlas.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; @@ -20,7 +21,9 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' * sub-paths explicitly rather than claiming all of /api/auth. * * PermissionsModule feeds getAppConfig's permissions block; AtlasModule feeds - * getTravelStats' hidden-countries subtraction. AuthService is + * getTravelStats' hidden-countries subtraction; MailerModule sends the + * password-reset mail. It is a leaf module of its own precisely so this import + * does not become AuthModule -> NotificationsModule -> AuthModule. AuthService is * exported for the in-container consumers (the domain *.mcp.ts demo guards, * OidcService, PasskeyEnabledGuard); PasskeyService for AdminService's passkey * reset; UserCleanupService for the two account-deletion paths (AdminService) @@ -28,7 +31,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' * through auth.bridge.ts. */ @Module({ - imports: [RateLimitModule, AuditModule, PermissionsModule, AtlasModule, TripMembershipModule, AppConfigModule], + imports: [RateLimitModule, AuditModule, PermissionsModule, AtlasModule, TripMembershipModule, MailerModule, AppConfigModule], controllers: [AuthPublicController, AuthController, PasskeyController], providers: [AuthService, PasskeyService, UserCleanupService, WebauthnConfigService], exports: [AuthService, PasskeyService, UserCleanupService], diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index aa5f56e91..328bd6635 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -32,7 +32,7 @@ import { avatarUrl } from '../common/avatarUrl'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { WebauthnConfigService } from './webauthn-config.service'; import { setAuthCookie, clearAuthCookie } from '../common/cookie'; -import { sendPasswordResetEmail } from '../../services/notifications'; +import { MailerService } from '../notifications/mailer/mailer.service'; import { getAppUrl } from '../../app-config'; import { ADMIN_SETTINGS_KEYS, @@ -140,6 +140,7 @@ export class AuthService implements OnModuleInit, OnModuleDestroy { private readonly membership: TripMembershipService, private readonly webauthn: WebauthnConfigService, private readonly userCleanup: UserCleanupService, + private readonly mailer: MailerService, ) {} // Cookie @@ -148,7 +149,7 @@ export class AuthService implements OnModuleInit, OnModuleDestroy { // Reset-email delivery (canonical app URL, never request headers) getAppUrl() { return getAppUrl(); } - sendPasswordResetEmail(email: string, url: string, userId: number | null) { return sendPasswordResetEmail(email, url, userId); } + sendPasswordResetEmail(email: string, url: string, userId: number | null) { return this.mailer.sendPasswordResetEmail(email, url, userId); } // ------------------------------------------------------------------------- // Toggles + tokens diff --git a/server/src/services/notifications/channelRegistry.ts b/server/src/nest/notifications/channel-registry.ts similarity index 87% rename from server/src/services/notifications/channelRegistry.ts rename to server/src/nest/notifications/channel-registry.ts index 5aae1caf5..da0f0838c 100644 --- a/server/src/services/notifications/channelRegistry.ts +++ b/server/src/nest/notifications/channel-registry.ts @@ -1,11 +1,16 @@ -import type { ExternalChannel } from '../../nest/notifications/notification-events'; +import type { ExternalChannel } from './notification-events'; -// The channel contract itself (ChannelMessage, ExternalChannel) lives in -// nest/notifications/notification-events.ts so that this registry, the -// preferences service and the transports can all name it without importing each -// other. Re-exported here for the consumers that reach the contract through the -// registry. -export type { ChannelMessage, ExternalChannel } from '../../nest/notifications/notification-events'; +/** + * The live set of notification channels. + * + * Deliberately a module singleton and not a provider. Two things read it from + * outside the container: notifications.bridge.ts constructs its own + * NotificationsService for the scheduler crons and the memories senders, and + * PluginRuntimeService pushes its channel getter in at onModuleInit. Make this a + * provider and the bridge gets a second, empty registry - plugin channels would + * then go quiet with no error anywhere. The built-ins are registered from + * NotificationsService's constructor, so every path that can dispatch has them. + */ /** Namespace for plugin channel ids, so a plugin can never claim `email`. */ export const PLUGIN_CHANNEL_PREFIX = 'plugin:'; diff --git a/server/src/nest/notifications/channels/builtins.ts b/server/src/nest/notifications/channels/builtins.ts new file mode 100644 index 000000000..3a789e8b1 --- /dev/null +++ b/server/src/nest/notifications/channels/builtins.ts @@ -0,0 +1,120 @@ +import { registerChannel } from '../channel-registry'; +import type { ChannelMessage, ExternalChannel } from '../notification-events'; +import type { MailerService } from '../mailer/mailer.service'; +import { resolveAdminNtfyUrl, resolveNtfyUrl, type NtfyService } from '../transports/ntfy.service'; +import type { WebhookService } from '../transports/webhook.service'; + +// The three built-in external channels, wrapping the transports that were free +// functions in services/notifications.ts before the fold. No delivery logic is +// rewritten here - it is only relocated behind the ExternalChannel interface so +// NotificationsService.send() can iterate instead of branching. + +function supportsAllButSynology(event: string): boolean { + return event !== 'synology_session_cleared'; +} + +export interface BuiltinChannelDeps { + mailer: MailerService; + webhook: WebhookService; + ntfy: NtfyService; +} + +/** + * Build the three built-in channels over the given transports. + * + * They take their dependencies as an argument rather than importing them, + * because the registry they land in is a module singleton while the transports + * are providers. Both NotificationsService instances - the container singleton + * and the one notifications.bridge.ts constructs - register from their + * constructor, so the registry is populated on every path that can dispatch. + * It used to happen through a side-effect import in the preferences service, + * which meant the built-ins existed only because somebody happened to import + * preferences; dropping that import would have silenced email, webhook and ntfy + * without a single error. + */ +export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDeps): ExternalChannel[] { + const emailChannel: ExternalChannel = { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + // Admin-scoped events (version_available) reach admins by email even when the + // admin has not put `email` in notification_channels — the admin global pref + // gates it instead. Verbatim from the pre-registry dispatch. + bypassesActiveToggleForAdminEvents: true, + supportsEvent: supportsAllButSynology, + isInstanceConfigured: () => mailer.isSmtpConfigured(), + isConfiguredFor: (userId) => !!mailer.getUserEmail(userId), + async sendToUser(userId, msg) { + const email = mailer.getUserEmail(userId); + if (!email) return false; + return mailer.sendEmail(email, msg.title, msg.body, userId, msg.navigateTarget); + }, + async test(userId) { + const email = mailer.getUserEmail(userId); + if (!email) return { success: false, error: 'No email address on file' }; + return mailer.testSmtp(email); + }, + }; + + const webhookChannel: ExternalChannel = { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + supportsAdminGlobal: true, + supportsEvent: supportsAllButSynology, + isConfiguredFor: (userId) => !!webhook.getUserWebhookUrl(userId), + async sendToUser(userId, msg) { + const url = webhook.getUserWebhookUrl(userId); + if (!url) return false; + return webhook.sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, tripName: msg.tripName, link: msg.url }); + }, + async sendGlobal(msg: ChannelMessage) { + const url = webhook.getAdminWebhookUrl(); + if (!url) return false; + return webhook.sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, link: msg.url }); + }, + async test(userId, override) { + const url = (typeof override?.url === 'string' && override.url) || webhook.getUserWebhookUrl(userId); + if (!url) return { success: false, error: 'No webhook URL configured' }; + return webhook.testWebhook(url); + }, + }; + + const ntfyChannel: ExternalChannel = { + id: 'ntfy', + source: 'builtin', + labelKey: 'settings.notificationPreferences.ntfy', + supportsAdminGlobal: true, + supportsEvent: supportsAllButSynology, + isConfiguredFor: (userId) => !!resolveNtfyUrl(ntfy.getAdminNtfyConfig(), ntfy.getUserNtfyConfig(userId)), + async sendToUser(userId, msg) { + const userCfg = ntfy.getUserNtfyConfig(userId); + const adminCfg = ntfy.getAdminNtfyConfig(); + const url = resolveNtfyUrl(adminCfg, userCfg); + if (!url) return false; + return ntfy.sendNtfy(url, userCfg?.token ?? adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url }); + }, + async sendGlobal(msg: ChannelMessage) { + const adminCfg = ntfy.getAdminNtfyConfig(); + const url = resolveAdminNtfyUrl(adminCfg); + if (!url) return false; + return ntfy.sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url }); + }, + async test(userId, override) { + const topic = typeof override?.topic === 'string' ? override.topic : ntfy.getUserNtfyConfig(userId)?.topic; + if (!topic) return { success: false, error: 'Could not resolve ntfy URL — missing topic' }; + return ntfy.testNtfy({ + topic, + server: typeof override?.server === 'string' ? override.server : null, + token: typeof override?.token === 'string' ? override.token : null, + }); + }, + }; + + return [emailChannel, webhookChannel, ntfyChannel]; +} + +/** Idempotent - safe to call from every entry point that needs the registry populated. */ +export function registerBuiltinChannels(deps: BuiltinChannelDeps): void { + for (const channel of buildBuiltinChannels(deps)) registerChannel(channel); +} diff --git a/server/src/nest/notifications/mailer/email-html.ts b/server/src/nest/notifications/mailer/email-html.ts new file mode 100644 index 000000000..e9171f4cd --- /dev/null +++ b/server/src/nest/notifications/mailer/email-html.ts @@ -0,0 +1,104 @@ +import { EMAIL_I18N as I18N, EVENT_TEXTS } from '@trek/shared/i18n/externalNotifications'; +import type { EventText, PasswordResetStrings } from '@trek/shared/i18n/externalNotifications'; +import { getAppUrl } from '../../../app-config'; +import type { NotifEventType } from '../notification-events'; + +/** + * Pure rendering for outgoing mail: the shell, the CTA and the password-reset + * body. No database, no transport, no DI - which is why it stays a plain module + * and not a provider. `getAppUrl()` is the canonical app URL from app-config, + * never a request header. + */ + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Localized title/body for an event. +export function getEventText(lang: string, event: NotifEventType, params: Record): EventText { + const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en; + const fn = texts[event] ?? EVENT_TEXTS.en[event]; + if (!fn) return { title: event, body: '' }; + return fn(params); +} + +export function buildEmailHtml( + subject: string, + body: string, + lang: string, + navigateTarget?: string, + rawBody = false, +): string { + const s = I18N[lang] || I18N.en; + const appUrl = getAppUrl(); + const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : appUrl || ''); + const safeSubject = escapeHtml(subject); + const safeBody = rawBody ? body : escapeHtml(body); + + return ` + + + + + +
+ + + + + + + ${ + appUrl + ? `` + : '' + } + + +
+ TREK +
TREK
+
Travel Resource & Exploration Kit
+
+

${safeSubject}

+
+

${safeBody}

+
+ ${s.openTrek} +
+

${s.footer}
${s.manage}

+

${s.madeWith} ♥ by Maurice · GitHub

+
+
+ +`; +} + +export function buildPasswordResetHtml( + subject: string, + strings: PasswordResetStrings, + recipient: string, + resetUrl: string, + lang: string, +): string { + const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`); + const safeBody = escapeHtml(strings.body); + const safeExpiry = escapeHtml(strings.expiry); + const safeIgnore = escapeHtml(strings.ignore); + const safeCta = escapeHtml(strings.ctaIntro); + const block = ` +

${safeGreeting},

+

${safeBody}

+

+ ${safeCta} +

+

${safeExpiry}

+

${safeIgnore}

+ `; + return buildEmailHtml(subject, block, lang, undefined, true); +} diff --git a/server/src/nest/notifications/mailer/mailer.module.ts b/server/src/nest/notifications/mailer/mailer.module.ts new file mode 100644 index 000000000..594915a81 --- /dev/null +++ b/server/src/nest/notifications/mailer/mailer.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MailerService } from './mailer.service'; + +/** + * Outgoing SMTP. A leaf module on purpose: AuthModule imports it for the + * password-reset mail, and NotificationsModule imports it for the email + * channel. Since NotificationsModule already imports AuthModule (the demo gate + * in NotificationsMcp), the mailer living in the notifications domain would + * close a hard cycle — this module is what keeps it a DAG without a forwardRef. + */ +@Module({ + providers: [MailerService], + exports: [MailerService], +}) +export class MailerModule {} diff --git a/server/src/nest/notifications/mailer/mailer.service.ts b/server/src/nest/notifications/mailer/mailer.service.ts new file mode 100644 index 000000000..870bdf40f --- /dev/null +++ b/server/src/nest/notifications/mailer/mailer.service.ts @@ -0,0 +1,185 @@ +import { Injectable } from '@nestjs/common'; +import nodemailer from 'nodemailer'; +import { PASSWORD_RESET_I18N } from '@trek/shared/i18n/externalNotifications'; +import { readEnv } from '../../../app-config'; +import { logError, logInfo, logDebug } from '../../audit/audit-log.logger'; +import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto'; +import { DatabaseService } from '../../database/database.service'; +import { buildEmailHtml, buildPasswordResetHtml } from './email-html'; + +interface SmtpConfig { + host: string; + port: number; + user: string; + pass: string; + from: string; + secure: boolean; +} + +/** + * Outgoing mail: SMTP config resolution, the password-reset delivery and the + * generic notification email. + * + * Its own leaf module rather than part of the notifications domain, and that is + * load-bearing: AuthService sends the password-reset mail, while + * NotificationsModule imports AuthModule for the demo gate in NotificationsMcp. + * Putting the mailer in NotificationsModule would close a hard module cycle that + * only a forwardRef could open again. + */ +@Injectable() +export class MailerService { + constructor(private readonly db: DatabaseService) {} + + private getAppSetting(key: string): string | null { + return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null; + } + + private getSmtpConfig(): SmtpConfig | null { + const smtpEnv = readEnv().smtp; + const host = smtpEnv.host || this.getAppSetting('smtp_host'); + const port = smtpEnv.port || this.getAppSetting('smtp_port'); + const user = smtpEnv.user || this.getAppSetting('smtp_user'); + const pass = smtpEnv.pass || decrypt_api_key(this.getAppSetting('smtp_pass')) || ''; + const from = smtpEnv.from || this.getAppSetting('smtp_from'); + if (!host || !port || !from) return null; + return { + host, + port: parseInt(port, 10), + user: user || '', + pass: pass || '', + from, + secure: parseInt(port, 10) === 465, + }; + } + + /** + * A transport per send, never a cached one. `skipTlsVerify` and the whole SMTP + * config are re-read on every call, so an admin changing the host, the password + * or the TLS setting takes effect on the next mail instead of after a restart. + * The three call sites below each built this inline before the fold; keeping it + * one method is the only change, and it keeps the freshness property visible. + */ + private createTransport(config: SmtpConfig) { + const skipTls = readEnv().smtp.skipTlsVerify || this.getAppSetting('smtp_skip_tls_verify') === 'true'; + return nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.user ? { user: config.user, pass: config.pass } : undefined, + ...(skipTls ? { tls: { rejectUnauthorized: false } } : {}), + }); + } + + /** Is SMTP configured at the instance level? (Independent of any one user's address.) */ + isSmtpConfigured(): boolean { + return !!(readEnv().smtp.host || this.getAppSetting('smtp_host')); + } + + getUserEmail(userId: number): string | null { + // Defense-in-depth (#1362): a guest's synthetic email must never be emailed. + return this.db.get<{ email: string }>( + 'SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0', userId, + )?.email || null; + } + + getUserLanguage(userId: number): string { + return this.db.get<{ value: string }>( + "SELECT value FROM settings WHERE user_id = ? AND key = 'language'", userId, + )?.value || 'en'; + } + + /** + * Delivers a password-reset link. When SMTP is configured the user + * receives an email. When it isn't, the link is logged to stdout in a + * clearly-fenced block so the self-hosting admin can hand it off by + * other means. In both cases the caller always gets a boolean that + * indicates only whether the caller should treat delivery as + * best-effort done — the API response to the user must NOT leak it. + */ + async sendPasswordResetEmail( + to: string, + resetUrl: string, + userId: number | null, + ): Promise<{ delivered: 'email' | 'log' | 'failed' }> { + const lang = userId ? this.getUserLanguage(userId) : 'en'; + const strings = PASSWORD_RESET_I18N[lang] || PASSWORD_RESET_I18N.en; + const smtpCfg = this.getSmtpConfig(); + + if (!smtpCfg) { + // No SMTP configured — log the link in a visually distinct block so + // the admin can relay it. Never log the associated user id/email + // content at a lower level, only what's needed. + + console.log( + `\n===== PASSWORD RESET LINK =====\n` + + `to: ${to}\n` + + `url: ${resetUrl}\n` + + `expires: 60 minutes\n` + + `(SMTP is not configured — deliver this link to the user manually.)\n` + + `================================\n`, + ); + logInfo(`Password reset link issued (no SMTP) for=${to}`); + return { delivered: 'log' }; + } + + try { + await this.createTransport(smtpCfg).sendMail({ + from: smtpCfg.from, + to, + subject: `TREK — ${strings.subject}`, + text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`, + html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang), + }); + logInfo(`Password reset email sent to=${to}`); + return { delivered: 'email' }; + } catch (err) { + logError(`Password reset email failed to=${to}: ${err instanceof Error ? err.message : err}`); + return { delivered: 'failed' }; + } + } + + async sendEmail( + to: string, + subject: string, + body: string, + userId?: number, + navigateTarget?: string, + ): Promise { + const config = this.getSmtpConfig(); + if (!config) return false; + + const lang = userId ? this.getUserLanguage(userId) : 'en'; + + try { + await this.createTransport(config).sendMail({ + from: config.from, + to, + subject: `TREK — ${subject}`, + text: body, + html: buildEmailHtml(subject, body, lang, navigateTarget), + }); + logInfo(`Email sent to=${to} subject="${subject}"`); + logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`); + return true; + } catch (err) { + logError(`Email send failed to=${to}: ${err instanceof Error ? err.message : err}`); + return false; + } + } + + async testSmtp(to: string): Promise<{ success: boolean; error?: string }> { + const config = this.getSmtpConfig(); + if (!config) return { success: false, error: 'SMTP not configured' }; + try { + await this.createTransport(config).sendMail({ + from: config.from, + to, + subject: 'TREK — Test Notification', + text: 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.', + }); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } + } +} diff --git a/server/src/nest/notifications/notification-preferences.service.ts b/server/src/nest/notifications/notification-preferences.service.ts new file mode 100644 index 000000000..5f627aedb --- /dev/null +++ b/server/src/nest/notifications/notification-preferences.service.ts @@ -0,0 +1,310 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { MailerService } from './mailer/mailer.service'; +import { listChannels } from './channel-registry'; +import { + ADMIN_SCOPED_EVENTS, + ALL_EVENT_TYPES, + INAPP_CHANNEL, + isAdminGlobalChannel, + type AdminGlobalChannel, + type ChannelDescriptor, + type ExternalChannel, + type NotifChannel, + type NotifEventType, +} from './notification-events'; + +export interface PreferencesMatrix { + preferences: Partial>>>; + /** The columns to render, in order. Replaces the old fixed-shape available_channels. */ + channels: ChannelDescriptor[]; + event_types: NotifEventType[]; + implemented_combos: Record; + defaults?: { ntfyServer: string | null }; +} + +/** + * Who gets which notification over which channel: the admin's channel switches, + * the per-user event opt-outs, and the matrix the settings UI renders. + * + * It reads the live channel set from the registry but never sends anything — + * the dispatch loop in NotificationsService applies what is decided here. + */ +@Injectable() +export class NotificationPreferencesService { + constructor( + private readonly db: DatabaseService, + private readonly mailer: MailerService, + ) {} + + private getAppSetting(key: string): string | null { + return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null; + } + + /** + * Channels implemented for an event. In-app takes everything; external channels + * decide for themselves (today: everything except `synology_session_cleared`). + */ + combosFor(event: NotifEventType): NotifChannel[] { + return [INAPP_CHANNEL, ...listChannels().filter(c => c.supportsEvent(event)).map(c => c.id)]; + } + + private allCombos(): Record { + const out: Record = {}; + for (const event of ALL_EVENT_TYPES) out[event] = this.combosFor(event); + return out; + } + + // ── Active channels (admin-configured) ──────────────────────────────────── + + /** + * Which channels the admin has enabled, as ids. + * Reads `notification_channels` (plural) with fallback to `notification_channel` (singular). + * + * BUILT-INS ONLY. A plugin channel is NOT gated on this list: a built-in always exists in + * the code and so needs an explicit switch, but a plugin channel only exists because an + * admin installed and enabled that plugin — that IS the opt-in. Requiring a second one + * meant a plugin channel could never be turned on at all (nothing writes a `plugin:` id + * into this CSV, and the admin toggle rebuilds it from the three built-in booleans, which + * would silently drop any that were). + */ + getActiveChannels(): NotifChannel[] { + const raw = this.getAppSetting('notification_channels') || this.getAppSetting('notification_channel') || 'none'; + if (raw === 'none') return []; + const builtins = new Set(listChannels().filter(c => c.source === 'builtin').map(c => c.id)); + return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c)); + } + + /** Is this channel switched on? Plugin channels are on by virtue of being live. */ + isChannelActive(channel: ExternalChannel): boolean { + return channel.source === 'plugin' || this.getActiveChannels().includes(channel.id); + } + + // ── Per-user preference checks ───────────────────────────────────────────── + + /** + * Returns true if the user has this event+channel enabled. + * Default (no row) = enabled. Only returns false if there's an explicit disabled row. + */ + isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean { + const row = this.db.get<{ enabled: number }>( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?', + userId, eventType, channel, + ); + return row === undefined || row.enabled === 1; + } + + // ── Preferences matrix ───────────────────────────────────────────────────── + + /** The in-app pseudo-channel — always active, never configurable. */ + private inAppDescriptor(): ChannelDescriptor { + return { + id: INAPP_CHANNEL, + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }; + } + + /** + * The channel columns for a scope. + * scope='user' — a column per channel the admin turned on in `notification_channels`. + * scope='admin' — a column per channel that has admin-global credentials. + */ + private describeChannels(userId: number, scope: 'user' | 'admin'): ChannelDescriptor[] { + const out: ChannelDescriptor[] = [this.inAppDescriptor()]; + + if (scope === 'admin') { + // Admin-scoped events go out over the admin's own global credentials, which + // are independent of the per-user `notification_channels` toggle. + const hasSmtp = this.mailer.isSmtpConfigured(); + const hasAdminWebhook = !!this.getAppSetting('admin_webhook_url'); + const hasAdminNtfy = !!this.getAppSetting('admin_ntfy_topic'); + const adminActive: Record = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy }; + for (const channel of listChannels()) { + // Plugin channels are user-scoped only — they never carry admin-global events. + if (channel.source !== 'builtin') continue; + const active = adminActive[channel.id] ?? false; + out.push({ + id: channel.id, + source: channel.source, + labelKey: channel.labelKey, + label: channel.label, + active, + configured: active, + }); + } + return out; + } + + for (const channel of listChannels()) { + out.push({ + id: channel.id, + source: channel.source, + labelKey: channel.labelKey, + label: channel.label, + settingsPath: channel.settingsPath, + // A live plugin channel is always a column. `configured` tells the user whether + // they still need to enter credentials — it does not hide the channel from them. + active: this.isChannelActive(channel), + configured: channel.isConfiguredFor(userId), + }); + } + return out; + } + + /** + * Returns the preferences matrix for a user. + * scope='user' — excludes admin-scoped events (for user settings page) + * scope='admin' — returns only admin-scoped events (for admin notifications tab) + */ + getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix { + const rows = this.db.all<{ event_type: string; channel: string; enabled: number }>( + 'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?', userId, + ); + + // Build a lookup from stored rows + const stored: Partial>>> = {}; + for (const row of rows) { + if (!stored[row.event_type]) stored[row.event_type] = {}; + stored[row.event_type]![row.channel] = row.enabled === 1; + } + + const implemented_combos = this.allCombos(); + + // Build the full matrix with defaults (true when no row exists) + const preferences: Partial>>> = {}; + + for (const eventType of ALL_EVENT_TYPES) { + preferences[eventType] = {}; + for (const channel of implemented_combos[eventType]) { + // Admin-scoped events use global settings for the built-in external channels + if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && isAdminGlobalChannel(channel)) { + preferences[eventType]![channel] = this.getAdminGlobalPref(eventType, channel); + } else { + preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true; + } + } + } + + // Filter event types by scope + const event_types = scope === 'admin' + ? ALL_EVENT_TYPES.filter(e => ADMIN_SCOPED_EVENTS.has(e)) + : ALL_EVENT_TYPES.filter(e => !ADMIN_SCOPED_EVENTS.has(e)); + + return { + preferences, + channels: this.describeChannels(userId, scope), + event_types, + implemented_combos, + ...(scope === 'user' && { defaults: { ntfyServer: this.getAppSetting('admin_ntfy_server') || null } }), + }; + } + + // ── Admin global preferences (stored in app_settings) ───────────────────── + + /** + * Returns the global admin preference for an event+channel. + * Stored in app_settings as `admin_notif_pref_{event}_{channel}`. + * Defaults to true (enabled) when no row exists. + */ + getAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel): boolean { + const val = this.getAppSetting(`admin_notif_pref_${event}_${channel}`); + return val !== '0'; + } + + private setAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel, enabled: boolean): void { + this.db.run('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', + `admin_notif_pref_${event}_${channel}`, + enabled ? '1' : '0', + ); + } + + // ── Preferences update ───────────────────────────────────────────────────── + + /** Shared helper for per-user channel preference upserts. */ + private applyUserChannelPrefs( + userId: number, + prefs: Partial>>>, + ): void { + const upsert = this.db.prepare( + 'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)' + ); + const del = this.db.prepare( + 'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ); + for (const [eventType, channels] of Object.entries(prefs)) { + if (!channels) continue; + for (const [channel, enabled] of Object.entries(channels)) { + if (enabled) { + // Remove explicit row — default is enabled + del.run(userId, eventType, channel); + } else { + upsert.run(userId, eventType, channel, 0); + } + } + } + } + + /** + * Bulk-update preferences from the matrix UI. + * Inserts disabled rows (enabled=0) and removes rows that are enabled (default). + */ + setPreferences( + userId: number, + prefs: Partial>>> + ): void { + this.db.transaction(() => this.applyUserChannelPrefs(userId, prefs)); + } + + /** + * Bulk-update admin notification preferences. + * email/webhook channels are stored globally in app_settings (not per-user). + * inapp channel remains per-user in notification_channel_preferences. + */ + setAdminPreferences( + userId: number, + prefs: Partial>>> + ): void { + // Split global (email/webhook) from per-user (inapp) prefs + const globalPrefs: Partial>>> = {}; + const userPrefs: Partial>>> = {}; + + for (const [eventType, channels] of Object.entries(prefs)) { + if (!channels) continue; + for (const [channel, enabled] of Object.entries(channels)) { + if (isAdminGlobalChannel(channel)) { + if (!globalPrefs[eventType]) globalPrefs[eventType] = {}; + globalPrefs[eventType]![channel] = enabled; + } else { + if (!userPrefs[eventType]) userPrefs[eventType] = {}; + userPrefs[eventType]![channel] = enabled; + } + } + } + + // Apply global prefs outside the transaction (they write to app_settings) + for (const [eventType, channels] of Object.entries(globalPrefs)) { + if (!channels) continue; + for (const [channel, enabled] of Object.entries(channels)) { + if (!isAdminGlobalChannel(channel)) continue; + this.setAdminGlobalPref(eventType as NotifEventType, channel, enabled); + } + } + + // Apply per-user (inapp) prefs in a transaction + this.db.transaction(() => this.applyUserChannelPrefs(userId, userPrefs)); + } + + // ── Instance-level readiness ────────────────────────────────────────────── + + /** SMTP set up at all? Kept here because the settings UI asks preferences, not the mailer. */ + isSmtpConfigured(): boolean { + return this.mailer.isSmtpConfigured(); + } + + isWebhookConfigured(): boolean { + return this.getActiveChannels().includes('webhook'); + } +} diff --git a/server/src/nest/notifications/notifications.bridge.ts b/server/src/nest/notifications/notifications.bridge.ts index ef1cc824c..f6fa3e66d 100644 --- a/server/src/nest/notifications/notifications.bridge.ts +++ b/server/src/nest/notifications/notifications.bridge.ts @@ -1,6 +1,10 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; +import { MailerService } from './mailer/mailer.service'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { NtfyService } from './transports/ntfy.service'; +import { WebhookService } from './transports/webhook.service'; import { NotificationsService } from './notifications.service'; import type { NotificationPayload } from './notifications.service'; @@ -19,9 +23,22 @@ import type { NotificationPayload } from './notifications.service'; * lazy senders inject. * * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto - * the shared better-sqlite3 singleton. + * the shared better-sqlite3 singleton, and the channel registry is module-scoped + * on purpose, so this instance and the DI singleton see the same channels — the + * built-ins it registers in its constructor and the plugin channels the runtime + * pushes in. A registry that were a provider would leave this instance empty and + * silence every plugin channel on the scheduler's path. */ -const notifications = new NotificationsService(new DatabaseService(db), new RealtimeService()); +const dbs = new DatabaseService(db); +const mailer = new MailerService(dbs); +const notifications = new NotificationsService( + dbs, + new RealtimeService(), + mailer, + new WebhookService(dbs), + new NtfyService(dbs), + new NotificationPreferencesService(dbs, mailer), +); export function send(payload: NotificationPayload): Promise { return notifications.send(payload); diff --git a/server/src/nest/notifications/notifications.module.ts b/server/src/nest/notifications/notifications.module.ts index e5cd6c225..230fa199b 100644 --- a/server/src/nest/notifications/notifications.module.ts +++ b/server/src/nest/notifications/notifications.module.ts @@ -2,16 +2,23 @@ import { Module } from '@nestjs/common'; import { NotificationsController } from './notifications.controller'; import { NotificationsMcp } from './notifications.mcp'; import { NotificationsService } from './notifications.service'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { NtfyService } from './transports/ntfy.service'; +import { WebhookService } from './transports/webhook.service'; +import { MailerModule } from './mailer/mailer.module'; import { AuthModule } from '../auth/auth.module'; /** Notifications domain (L6 leaf module). Registered in AppModule. - * AuthModule feeds NotificationsMcp's demo gate; NotificationsService is - * exported for in-container consumers (AdminController's dev test send and + * AuthModule feeds NotificationsMcp's demo gate; MailerModule carries SMTP, + * which lives outside this module so AuthService can send the password-reset + * mail without AuthModule and NotificationsModule importing each other. + * NotificationsService and NotificationPreferencesService are exported for + * in-container consumers (AdminController's dev test send and preferences tab, * the plugin RPC host's PluginHostDepsFactory). */ @Module({ - imports: [AuthModule], + imports: [AuthModule, MailerModule], controllers: [NotificationsController], - providers: [NotificationsService, NotificationsMcp], - exports: [NotificationsService], + providers: [NotificationsService, NotificationPreferencesService, WebhookService, NtfyService, NotificationsMcp], + exports: [NotificationsService, NotificationPreferencesService], }) export class NotificationsModule {} diff --git a/server/src/nest/notifications/notifications.service.ts b/server/src/nest/notifications/notifications.service.ts index 5f7751fe6..7175406a4 100644 --- a/server/src/nest/notifications/notifications.service.ts +++ b/server/src/nest/notifications/notifications.service.ts @@ -2,24 +2,12 @@ import { Injectable } from '@nestjs/common'; import type { ChannelTestResult } from '@trek/shared'; import { readEnv, getAppUrl } from '../../app-config'; import { logDebug, logError } from '../audit/audit-log.logger'; -import { - testSmtp, - testWebhook, - testNtfy, - getAdminWebhookUrl, - getUserWebhookUrl, - getUserNtfyConfig, - getAdminNtfyConfig, - getEventText, - getUserLanguage, -} from '../../services/notifications'; -import { - getPreferencesMatrix, - setPreferences, - getActiveChannels, - isEnabledForEvent, - getAdminGlobalPref, -} from '../../services/notificationPreferencesService'; +import { getEventText } from './mailer/email-html'; +import { MailerService } from './mailer/mailer.service'; +import { NtfyService, type NtfyConfig } from './transports/ntfy.service'; +import { WebhookService } from './transports/webhook.service'; +import { NotificationPreferencesService, type PreferencesMatrix } from './notification-preferences.service'; +import { registerBuiltinChannels } from './channels/builtins'; import { ADMIN_SCOPED_EVENTS, isAdminGlobalChannel, @@ -27,14 +15,12 @@ import { type ExternalChannel, type NotifEventType, } from './notification-events'; -import { getChannel, listChannels } from '../../services/notifications/channelRegistry'; +import { getChannel, listChannels } from './channel-registry'; import { getAction } from './in-app-actions'; import { avatarUrl } from '../common/avatarUrl'; import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; -type NtfyConfig = ReturnType; -type PreferencesMatrix = ReturnType; // SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has // no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC @@ -276,6 +262,7 @@ function shouldSendToUser( event: NotifEventType, recipientId: number, activeChannels: string[], + prefs: NotificationPreferencesService, ): boolean { if (!channel.supportsEvent(event)) return false; if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false; @@ -283,12 +270,12 @@ function shouldSendToUser( if (ADMIN_SCOPED_EVENTS.has(event)) { if (!channel.bypassesActiveToggleForAdminEvents) return false; if (!isAdminGlobalChannel(channel.id)) return false; - if (!getAdminGlobalPref(event, channel.id)) return false; + if (!prefs.getAdminGlobalPref(event, channel.id)) return false; } else { // A built-in needs the admin's explicit switch; a plugin channel is on because the // admin enabled the plugin — that IS the opt-in (see getActiveChannels). if (channel.source === 'builtin' && !activeChannels.includes(channel.id)) return false; - if (!isEnabledForEvent(recipientId, event, channel.id)) return false; + if (!prefs.isEnabledForEvent(recipientId, event, channel.id)) return false; } return channel.isConfiguredFor(recipientId); @@ -304,10 +291,10 @@ function shouldSendToUser( * was originally patched on only one of three), and `respond` claims the * response atomically BEFORE running the action handler (double-submits can * no longer execute the action twice; a handler failure releases the claim). - * Channel transports, the preference matrix and the channel - * registry stay plain-module imports (`services/notifications*`, - * `services/notificationPreferencesService`) — they are classified as - * infra helpers, not wave material, in `migration-graph.md`. + * The channel transports and the preference matrix are injected providers + * since the notifications fold; the channel registry stays a module singleton + * because notifications.bridge.ts and the plugin runtime both reach it from + * outside the container. * * Non-Nest consumers (scheduler, legacy adminService/memories, the lazy * fire-and-forget sends in migrated Nest services) go through @@ -319,10 +306,20 @@ export class NotificationsService { constructor( private readonly db: DatabaseService, private readonly realtime: RealtimeService, - ) {} + private readonly mailer: MailerService, + private readonly webhook: WebhookService, + private readonly ntfy: NtfyService, + private readonly prefs: NotificationPreferencesService, + ) { + // The registry is a module singleton shared with notifications.bridge.ts and + // the plugin runtime, so registering from here means every path that can + // dispatch has the built-ins — including the bridge instance, which never + // runs onModuleInit. registerChannel is an idempotent Map.set. + registerBuiltinChannels({ mailer, webhook, ntfy }); + } getPreferences(userId: number, role: string): PreferencesMatrix { - return getPreferencesMatrix(userId, role, 'user'); + return this.prefs.getPreferencesMatrix(userId, role, 'user'); } /** Send a test notification over any registered channel (built-in or plugin). */ @@ -338,36 +335,36 @@ export class NotificationsService { } } - setPreferences(userId: number, body: Parameters[1]): void { - setPreferences(userId, body); + setPreferences(userId: number, body: Parameters[1]): void { + this.prefs.setPreferences(userId, body); } testSmtp(to: string): Promise { - return testSmtp(to); + return this.mailer.testSmtp(to); } testWebhook(url: string): Promise { - return testWebhook(url); + return this.webhook.testWebhook(url); } testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise { - return testNtfy(cfg); + return this.ntfy.testNtfy(cfg); } userWebhookUrl(userId: number): string | null { - return getUserWebhookUrl(userId); + return this.webhook.getUserWebhookUrl(userId); } adminWebhookUrl(): string | null { - return getAdminWebhookUrl(); + return this.webhook.getAdminWebhookUrl(); } userNtfyConfig(userId: number): NtfyConfig | null { - return getUserNtfyConfig(userId); + return this.ntfy.getUserNtfyConfig(userId); } adminNtfyConfig(): NtfyConfig { - return getAdminNtfyConfig(); + return this.ntfy.getAdminNtfyConfig(); } // ── In-app notification store (from services/inAppNotifications.ts) ─────── @@ -429,7 +426,7 @@ export class NotificationsService { for (const recipientId of recipients) { // Check per-user in-app preference if an event_type is provided - if (input.event_type && !isEnabledForEvent(recipientId, input.event_type, 'inapp')) { + if (input.event_type && !this.prefs.isEnabledForEvent(recipientId, input.event_type, 'inapp')) { continue; } @@ -699,7 +696,7 @@ export class NotificationsService { } } const config = configEntry ?? FALLBACK_EVENT_CONFIG; - const activeChannels = getActiveChannels(); + const activeChannels = this.prefs.getActiveChannels(); const channels = listChannels(); const appUrl = getAppUrl(); @@ -719,7 +716,7 @@ export class NotificationsService { const promises: Promise[] = []; // ── In-app ────────────────────────────────────────────────────────── - if (isEnabledForEvent(recipientId, event, 'inapp')) { + if (this.prefs.isEnabledForEvent(recipientId, event, 'inapp')) { const inAppType = inApp?.type ?? config.inAppType; let notifInput: NotificationInput; @@ -776,9 +773,9 @@ export class NotificationsService { // One loop over the registry. The message is rendered once per recipient, in // their language, and handed to every channel that wants it — so a plugin // channel never touches i18n. - const deliverable = channels.filter(ch => shouldSendToUser(ch, event, recipientId, activeChannels)); + const deliverable = channels.filter(ch => shouldSendToUser(ch, event, recipientId, activeChannels, this.prefs)); if (deliverable.length > 0) { - const lang = getUserLanguage(recipientId); + const lang = this.mailer.getUserLanguage(recipientId); const { title, body } = getEventText(lang, event, params); const msg: ChannelMessage = { event, @@ -804,7 +801,7 @@ export class NotificationsService { // Always rendered in English — there is no single recipient to take a language from. if (scope === 'admin') { const globalChannels = channels.filter( - ch => ch.sendGlobal && ch.supportsEvent(event) && isAdminGlobalChannel(ch.id) && getAdminGlobalPref(event, ch.id), + ch => ch.sendGlobal && ch.supportsEvent(event) && isAdminGlobalChannel(ch.id) && this.prefs.getAdminGlobalPref(event, ch.id), ); if (globalChannels.length > 0) { const { title, body } = getEventText('en', event, params); diff --git a/server/src/nest/notifications/transports/ntfy.service.ts b/server/src/nest/notifications/transports/ntfy.service.ts new file mode 100644 index 000000000..23288f485 --- /dev/null +++ b/server/src/nest/notifications/transports/ntfy.service.ts @@ -0,0 +1,162 @@ +import { Injectable } from '@nestjs/common'; +import { logDebug, logError, logInfo } from '../../audit/audit-log.logger'; +import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto'; +import { DatabaseService } from '../../database/database.service'; +import { checkSsrf, createPinnedDispatcher } from '../../../utils/ssrfGuard'; +import type { NotifEventType } from '../notification-events'; + +export interface NtfyConfig { + server: string | null; + topic: string | null; + token: string | null; +} + +/** Priority and tags mapped to each notification event type. */ +const NTFY_EVENT_META: Partial> = { + trip_invite: { priority: 4, tags: ['loudspeaker'] }, + booking_change: { priority: 3, tags: ['calendar'] }, + trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] }, + vacay_invite: { priority: 4, tags: ['palm_tree'] }, + todo_due: { priority: 4, tags: ['ballot_box_with_check'] }, + vacay_share: { priority: 3, tags: ['palm_tree'] }, + collection_invite: { priority: 3, tags: ['bookmark'] }, + photos_shared: { priority: 3, tags: ['camera'] }, + collab_message: { priority: 3, tags: ['speech_balloon'] }, + packing_tagged: { priority: 3, tags: ['luggage'] }, + version_available: { priority: 4, tags: ['package'] }, + synology_session_cleared: { priority: 3, tags: ['warning'] }, +}; +const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] }; + +/** + * Resolve the ntfy POST URL for a per-user send. The topic must come from the + * user's own config — the admin topic is reserved for admin-scoped sends + * (see resolveAdminNtfyUrl). Only the server falls back to the admin default. + * Returns null if the user has no topic. + */ +export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null { + const topic = userCfg?.topic; + if (!topic) return null; + const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, ''); + return `${base}/${encodeURIComponent(topic)}`; +} + +/** Resolve the ntfy POST URL for admin-scoped sends. Returns null if no admin topic. */ +export function resolveAdminNtfyUrl(adminCfg: NtfyConfig): string | null { + if (!adminCfg.topic) return null; + const base = (adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, ''); + return `${base}/${encodeURIComponent(adminCfg.topic)}`; +} + +function encodeHeaderValue(value: string): string { + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) > 0x7f) { + return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`; + } + } + return value; +} + +/** ntfy push: the per-user and admin topic config, and the header-based POST. */ +@Injectable() +export class NtfyService { + constructor(private readonly db: DatabaseService) {} + + private getAppSetting(key: string): string | null { + return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null; + } + + getUserNtfyConfig(userId: number): NtfyConfig | null { + const rows = this.db.all<{ key: string; value: string }>( + "SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')", + userId, + ); + if (rows.length === 0) return null; + const map: Record = {}; + for (const r of rows) map[r.key] = r.value; + return { + topic: map['ntfy_topic'] || null, + server: map['ntfy_server'] || null, + token: map['ntfy_token'] ? decrypt_api_key(map['ntfy_token']) : null, + }; + } + + getAdminNtfyConfig(): NtfyConfig { + const topic = this.getAppSetting('admin_ntfy_topic') || null; + const server = this.getAppSetting('admin_ntfy_server') || null; + const rawToken = this.getAppSetting('admin_ntfy_token') || null; + return { + topic, + server, + token: rawToken ? decrypt_api_key(rawToken) : null, + }; + } + + async sendNtfy( + url: string, + token: string | null, + payload: { event: string; title: string; body: string; link?: string }, + ): Promise { + if (!url) return false; + + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) { + logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`); + return false; + } + + const meta = NTFY_EVENT_META[payload.event as NotifEventType] ?? NTFY_DEFAULT_META; + + // ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers + const headers: Record = { + Title: encodeHeaderValue(payload.title), + Priority: String(meta.priority), + }; + if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(','); + if (payload.link) headers['Click'] = encodeHeaderValue(payload.link); + if (token) headers['Authorization'] = `Bearer ${token}`; + + try { + const res = await fetch(url, { + method: 'POST', + headers, + body: payload.body, + signal: AbortSignal.timeout(10000), + dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), + } as RequestInit); + + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + logError(`Ntfy HTTP ${res.status}: ${errBody}`); + return false; + } + + logInfo(`Ntfy sent event=${payload.event}`); + logDebug(`Ntfy url=${url} priority=${meta.priority} tags=${meta.tags.join(',')}`); + return true; + } catch (err) { + logError(`Ntfy failed event=${payload.event}: ${err instanceof Error ? err.message : err}`); + return false; + } + } + + async testNtfy(cfg: { + topic: string; + server?: string | null; + token?: string | null; + }): Promise<{ success: boolean; error?: string }> { + const adminCfg = this.getAdminNtfyConfig(); + const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null }); + if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' }; + try { + const sent = await this.sendNtfy(url, cfg.token ?? null, { + event: 'test', + title: 'Test Notification', + body: 'This is a test notification from TREK. If you received this, your ntfy configuration is working correctly.', + }); + return sent ? { success: true } : { success: false, error: 'Failed to send ntfy notification' }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } + } +} diff --git a/server/src/nest/notifications/transports/webhook.service.ts b/server/src/nest/notifications/transports/webhook.service.ts new file mode 100644 index 000000000..2647e929c --- /dev/null +++ b/server/src/nest/notifications/transports/webhook.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common'; +import { logDebug, logError, logInfo } from '../../audit/audit-log.logger'; +import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto'; +import { DatabaseService } from '../../database/database.service'; +import { checkSsrf, createPinnedDispatcher } from '../../../utils/ssrfGuard'; + +/** + * Renders the outgoing body. Discord and Slack get their native shapes; anything + * else gets the raw payload. Pure, so the shape can be asserted without a send. + */ +export function buildWebhookBody( + url: string, + payload: { event: string; title: string; body: string; tripName?: string; link?: string }, +): string { + const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url); + const isSlack = /hooks\.slack\.com\//.test(url); + + if (isDiscord) { + return JSON.stringify({ + embeds: [ + { + title: `📍 ${payload.title}`, + description: payload.body, + url: payload.link, + color: 0x3b82f6, + footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' }, + timestamp: new Date().toISOString(), + }, + ], + }); + } + + if (isSlack) { + const trip = payload.tripName ? ` • _${payload.tripName}_` : ''; + const link = payload.link ? `\n<${payload.link}|Open in TREK>` : ''; + return JSON.stringify({ + text: `*${payload.title}*\n${payload.body}${trip}${link}`, + }); + } + + return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }); +} + +/** Outgoing webhooks: the per-user and the admin-global URL, and the POST itself. */ +@Injectable() +export class WebhookService { + constructor(private readonly db: DatabaseService) {} + + getUserWebhookUrl(userId: number): string | null { + const value = this.db.get<{ value: string }>( + "SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'", userId, + )?.value || null; + return value ? decrypt_api_key(value) : null; + } + + getAdminWebhookUrl(): string | null { + const value = this.db.get<{ value: string }>( + 'SELECT value FROM app_settings WHERE key = ?', 'admin_webhook_url', + )?.value || null; + return value ? decrypt_api_key(value) : null; + } + + async sendWebhook( + url: string, + payload: { event: string; title: string; body: string; tripName?: string; link?: string }, + ): Promise { + if (!url) return false; + + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) { + logError(`Webhook blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`); + return false; + } + + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: buildWebhookBody(url, payload), + signal: AbortSignal.timeout(10000), + dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), + } as RequestInit); + + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + logError(`Webhook HTTP ${res.status}: ${errBody}`); + return false; + } + + logInfo(`Webhook sent event=${payload.event} trip=${payload.tripName || '-'}`); + logDebug(`Webhook url=${url} payload=${buildWebhookBody(url, payload).substring(0, 500)}`); + return true; + } catch (err) { + logError(`Webhook failed event=${payload.event}: ${err instanceof Error ? err.message : err}`); + return false; + } + } + + async testWebhook(url: string): Promise<{ success: boolean; error?: string }> { + try { + const sent = await this.sendWebhook(url, { + event: 'test', + title: 'Test Notification', + body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.', + }); + return sent ? { success: true } : { success: false, error: 'Failed to send webhook' }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } + } +} diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 3be09f5c2..2851ce6e9 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -4,7 +4,8 @@ import { DatabaseService } from '../database/database.service'; import { pluginsEnabled } from './kill-switch'; import { setPluginEventSink } from '../../plugin-event-sink'; import { setUserDeletedSink } from '../../plugin-user-lifecycle'; -import { setPluginChannelSource, pluginChannelId, type ChannelMessage, type ExternalChannel } from '../../services/notifications/channelRegistry'; +import { setPluginChannelSource, pluginChannelId } from '../notifications/channel-registry'; +import type { ChannelMessage, ExternalChannel } from '../notifications/notification-events'; import { readUserSettingsDecrypted, hasRequiredUserSettings } from './plugins.service'; import { PLUGIN_CHANNEL_EVENTS } from './install/manifest'; import { stripEmoji } from './text-sanitize'; diff --git a/server/src/services/notificationPreferencesService.ts b/server/src/services/notificationPreferencesService.ts deleted file mode 100644 index 0a5f349b6..000000000 --- a/server/src/services/notificationPreferencesService.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type Database from 'better-sqlite3'; -import { db } from '../db/database'; -import { isSmtpConfigured } from './notifications'; -import { - ADMIN_SCOPED_EVENTS, - ALL_EVENT_TYPES, - INAPP_CHANNEL, - isAdminGlobalChannel, - type AdminGlobalChannel, - type ChannelDescriptor, - type ExternalChannel, - type NotifChannel, - type NotifEventType, -} from '../nest/notifications/notification-events'; -import { listChannels } from './notifications/channelRegistry'; -// Side-effect import: populates the registry with email/webhook/ntfy. Safe from -// here — builtins only reaches ../notifications, which imports this module's -// types with `import type`, so there is no runtime cycle. -import './notifications/builtins'; - -/** - * Channels implemented for an event. In-app takes everything; external channels - * decide for themselves (today: everything except `synology_session_cleared`). - */ -export function combosFor(event: NotifEventType): NotifChannel[] { - return [INAPP_CHANNEL, ...listChannels().filter(c => c.supportsEvent(event)).map(c => c.id)]; -} - -function allCombos(): Record { - const out: Record = {}; - for (const event of ALL_EVENT_TYPES) out[event] = combosFor(event); - return out; -} - -// ── Helpers ──────────────────────────────────────────────────────────────── - -function getAppSetting(key: string): string | null { - return (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || null; -} - -// ── Active channels (admin-configured) ──────────────────────────────────── - -/** - * Which channels the admin has enabled, as ids. - * Reads `notification_channels` (plural) with fallback to `notification_channel` (singular). - * - * BUILT-INS ONLY. A plugin channel is NOT gated on this list: a built-in always exists in - * the code and so needs an explicit switch, but a plugin channel only exists because an - * admin installed and enabled that plugin — that IS the opt-in. Requiring a second one - * meant a plugin channel could never be turned on at all (nothing writes a `plugin:` id - * into this CSV, and the admin toggle rebuilds it from the three built-in booleans, which - * would silently drop any that were). - */ -export function getActiveChannels(): NotifChannel[] { - const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none'; - if (raw === 'none') return []; - const builtins = new Set(listChannels().filter(c => c.source === 'builtin').map(c => c.id)); - return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c)); -} - -/** Is this channel switched on? Plugin channels are on by virtue of being live. */ -export function isChannelActive(channel: ExternalChannel): boolean { - return channel.source === 'plugin' || getActiveChannels().includes(channel.id); -} - -// ── Per-user preference checks ───────────────────────────────────────────── - -/** - * Returns true if the user has this event+channel enabled. - * Default (no row) = enabled. Only returns false if there's an explicit disabled row. - */ -export function isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean { - const row = db.prepare( - 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' - ).get(userId, eventType, channel) as { enabled: number } | undefined; - return row === undefined || row.enabled === 1; -} - -// ── Preferences matrix ───────────────────────────────────────────────────── - -export interface PreferencesMatrix { - preferences: Partial>>>; - /** The columns to render, in order. Replaces the old fixed-shape available_channels. */ - channels: ChannelDescriptor[]; - event_types: NotifEventType[]; - implemented_combos: Record; - defaults?: { ntfyServer: string | null }; -} - -/** The in-app pseudo-channel — always active, never configurable. */ -function inAppDescriptor(): ChannelDescriptor { - return { - id: INAPP_CHANNEL, - source: 'builtin', - labelKey: 'settings.notificationPreferences.inapp', - active: true, - configured: true, - }; -} - -/** - * The channel columns for a scope. - * scope='user' — a column per channel the admin turned on in `notification_channels`. - * scope='admin' — a column per channel that has admin-global credentials. - */ -function describeChannels(userId: number, scope: 'user' | 'admin'): ChannelDescriptor[] { - const out: ChannelDescriptor[] = [inAppDescriptor()]; - - if (scope === 'admin') { - // Admin-scoped events go out over the admin's own global credentials, which - // are independent of the per-user `notification_channels` toggle. - const hasSmtp = isSmtpConfigured(); - const hasAdminWebhook = !!getAppSetting('admin_webhook_url'); - const hasAdminNtfy = !!getAppSetting('admin_ntfy_topic'); - const adminActive: Record = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy }; - for (const channel of listChannels()) { - // Plugin channels are user-scoped only — they never carry admin-global events. - if (channel.source !== 'builtin') continue; - const active = adminActive[channel.id] ?? false; - out.push({ - id: channel.id, - source: channel.source, - labelKey: channel.labelKey, - label: channel.label, - active, - configured: active, - }); - } - return out; - } - - for (const channel of listChannels()) { - out.push({ - id: channel.id, - source: channel.source, - labelKey: channel.labelKey, - label: channel.label, - settingsPath: channel.settingsPath, - // A live plugin channel is always a column. `configured` tells the user whether - // they still need to enter credentials — it does not hide the channel from them. - active: isChannelActive(channel), - configured: channel.isConfiguredFor(userId), - }); - } - return out; -} - -/** - * Returns the preferences matrix for a user. - * scope='user' — excludes admin-scoped events (for user settings page) - * scope='admin' — returns only admin-scoped events (for admin notifications tab) - */ -export function getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix { - const rows = db.prepare( - 'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?' - ).all(userId) as Array<{ event_type: string; channel: string; enabled: number }>; - - // Build a lookup from stored rows - const stored: Partial>>> = {}; - for (const row of rows) { - if (!stored[row.event_type]) stored[row.event_type] = {}; - stored[row.event_type]![row.channel] = row.enabled === 1; - } - - const implemented_combos = allCombos(); - - // Build the full matrix with defaults (true when no row exists) - const preferences: Partial>>> = {}; - - for (const eventType of ALL_EVENT_TYPES) { - preferences[eventType] = {}; - for (const channel of implemented_combos[eventType]) { - // Admin-scoped events use global settings for the built-in external channels - if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && isAdminGlobalChannel(channel)) { - preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel); - } else { - preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true; - } - } - } - - // Filter event types by scope - const event_types = scope === 'admin' - ? ALL_EVENT_TYPES.filter(e => ADMIN_SCOPED_EVENTS.has(e)) - : ALL_EVENT_TYPES.filter(e => !ADMIN_SCOPED_EVENTS.has(e)); - - return { - preferences, - channels: describeChannels(userId, scope), - event_types, - implemented_combos, - ...(scope === 'user' && { defaults: { ntfyServer: getAppSetting('admin_ntfy_server') || null } }), - }; -} - -// ── Admin global preferences (stored in app_settings) ───────────────────── - -/** - * Returns the global admin preference for an event+channel. - * Stored in app_settings as `admin_notif_pref_{event}_{channel}`. - * Defaults to true (enabled) when no row exists. - */ -export function getAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel): boolean { - const val = getAppSetting(`admin_notif_pref_${event}_${channel}`); - return val !== '0'; -} - -function setAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel, enabled: boolean): void { - db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run( - `admin_notif_pref_${event}_${channel}`, - enabled ? '1' : '0' - ); -} - -// ── Preferences update ───────────────────────────────────────────────────── - -// ── Shared helper for per-user channel preference upserts ───────────────── - -function applyUserChannelPrefs( - userId: number, - prefs: Partial>>>, - upsert: Database.Statement, - del: Database.Statement -): void { - for (const [eventType, channels] of Object.entries(prefs)) { - if (!channels) continue; - for (const [channel, enabled] of Object.entries(channels)) { - if (enabled) { - // Remove explicit row — default is enabled - del.run(userId, eventType, channel); - } else { - upsert.run(userId, eventType, channel, 0); - } - } - } -} - -/** - * Bulk-update preferences from the matrix UI. - * Inserts disabled rows (enabled=0) and removes rows that are enabled (default). - */ -export function setPreferences( - userId: number, - prefs: Partial>>> -): void { - const upsert = db.prepare( - 'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)' - ); - const del = db.prepare( - 'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' - ); - db.transaction(() => applyUserChannelPrefs(userId, prefs, upsert, del))(); -} - -/** - * Bulk-update admin notification preferences. - * email/webhook channels are stored globally in app_settings (not per-user). - * inapp channel remains per-user in notification_channel_preferences. - */ -export function setAdminPreferences( - userId: number, - prefs: Partial>>> -): void { - const upsert = db.prepare( - 'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)' - ); - const del = db.prepare( - 'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' - ); - - // Split global (email/webhook) from per-user (inapp) prefs - const globalPrefs: Partial>>> = {}; - const userPrefs: Partial>>> = {}; - - for (const [eventType, channels] of Object.entries(prefs)) { - if (!channels) continue; - for (const [channel, enabled] of Object.entries(channels)) { - if (isAdminGlobalChannel(channel)) { - if (!globalPrefs[eventType]) globalPrefs[eventType] = {}; - globalPrefs[eventType]![channel] = enabled; - } else { - if (!userPrefs[eventType]) userPrefs[eventType] = {}; - userPrefs[eventType]![channel] = enabled; - } - } - } - - // Apply global prefs outside the transaction (they write to app_settings) - for (const [eventType, channels] of Object.entries(globalPrefs)) { - if (!channels) continue; - for (const [channel, enabled] of Object.entries(channels)) { - if (!isAdminGlobalChannel(channel)) continue; - setAdminGlobalPref(eventType as NotifEventType, channel, enabled); - } - } - - // Apply per-user (inapp) prefs in a transaction - db.transaction(() => applyUserChannelPrefs(userId, userPrefs, upsert, del))(); -} - -// ── SMTP availability helper (for authService) ───────────────────────────── -// Lives in ./notifications now (the module that owns SMTP config); re-exported -// here so existing importers keep working. - -export { isSmtpConfigured }; - -export function isWebhookConfigured(): boolean { - return getActiveChannels().includes('webhook'); -} diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts deleted file mode 100644 index d30121c1f..000000000 --- a/server/src/services/notifications.ts +++ /dev/null @@ -1,546 +0,0 @@ -import { getAppUrl, readEnv } from '../app-config'; -import { db } from '../db/database'; -import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard'; -import { decrypt_api_key } from '../nest/common/crypto/apiKeyCrypto'; -import { logInfo, logDebug, logError } from '../nest/audit/audit-log.logger'; -// ── Types ────────────────────────────────────────────────────────────────── - -import type { NotifEventType } from '../nest/notifications/notification-events'; -import { EMAIL_I18N as I18N, EVENT_TEXTS, PASSWORD_RESET_I18N } from '@trek/shared/i18n/externalNotifications'; -import type { - EmailStrings, - EventText, - PasswordResetStrings, -} from '@trek/shared/i18n/externalNotifications'; - -import nodemailer from 'nodemailer'; - -interface SmtpConfig { - host: string; - port: number; - user: string; - pass: string; - from: string; - secure: boolean; -} - -// ── HTML escaping ────────────────────────────────────────────────────────── - -function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// ── Settings helpers ─────────────────────────────────────────────────────── - -function getAppSetting(key: string): string | null { - return ( - (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || - null - ); -} - -function getSmtpConfig(): SmtpConfig | null { - const smtpEnv = readEnv().smtp; - const host = smtpEnv.host || getAppSetting('smtp_host'); - const port = smtpEnv.port || getAppSetting('smtp_port'); - const user = smtpEnv.user || getAppSetting('smtp_user'); - const pass = smtpEnv.pass || decrypt_api_key(getAppSetting('smtp_pass')) || ''; - const from = smtpEnv.from || getAppSetting('smtp_from'); - if (!host || !port || !from) return null; - return { - host, - port: parseInt(port, 10), - user: user || '', - pass: pass || '', - from, - secure: parseInt(port, 10) === 465, - }; -} - -/** Is SMTP configured at the instance level? (Independent of any one user's address.) */ -export function isSmtpConfigured(): boolean { - return !!(readEnv().smtp.host || getAppSetting('smtp_host')); -} - -export function getUserEmail(userId: number): string | null { - // Defense-in-depth (#1362): a guest's synthetic email must never be emailed. - return ( - ( - db.prepare('SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0').get(userId) as - | { email: string } - | undefined - )?.email || null - ); -} - -export function getUserLanguage(userId: number): string { - return ( - ( - db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as - | { value: string } - | undefined - )?.value || 'en' - ); -} - -export function getUserWebhookUrl(userId: number): string | null { - const value = - ( - db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as - | { value: string } - | undefined - )?.value || null; - return value ? decrypt_api_key(value) : null; -} - -export function getAdminWebhookUrl(): string | null { - const value = getAppSetting('admin_webhook_url') || null; - return value ? decrypt_api_key(value) : null; -} - -// ── Email i18n strings — imported from @trek/shared/i18n/externalNotifications ── - -// EVENT_TEXTS imported from @trek/shared/i18n/externalNotifications - -// Get localized event text -export function getEventText(lang: string, event: NotifEventType, params: Record): EventText { - const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en; - const fn = texts[event] ?? EVENT_TEXTS.en[event]; - if (!fn) return { title: event, body: '' }; - return fn(params); -} - -// ── Email HTML builder ───────────────────────────────────────────────────── - -export function buildEmailHtml( - subject: string, - body: string, - lang: string, - navigateTarget?: string, - rawBody = false, -): string { - const s = I18N[lang] || I18N.en; - const appUrl = getAppUrl(); - const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : appUrl || ''); - const safeSubject = escapeHtml(subject); - const safeBody = rawBody ? body : escapeHtml(body); - - return ` - - - - - -
- - - - - - - ${ - appUrl - ? `` - : '' - } - - -
- TREK -
TREK
-
Travel Resource & Exploration Kit
-
-

${safeSubject}

-
-

${safeBody}

-
- ${s.openTrek} -
-

${s.footer}
${s.manage}

-

${s.madeWith} ♥ by Maurice · GitHub

-
-
- -`; -} - -// ── Send functions ───────────────────────────────────────────────────────── - -// ── Password reset email ─────────────────────────────────────────────────── - -// PASSWORD_RESET_I18N imported from @trek/shared/i18n/externalNotifications - -function buildPasswordResetHtml( - subject: string, - strings: PasswordResetStrings, - recipient: string, - resetUrl: string, - lang: string, -): string { - const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`); - const safeBody = escapeHtml(strings.body); - const safeExpiry = escapeHtml(strings.expiry); - const safeIgnore = escapeHtml(strings.ignore); - const safeCta = escapeHtml(strings.ctaIntro); - const block = ` -

${safeGreeting},

-

${safeBody}

-

- ${safeCta} -

-

${safeExpiry}

-

${safeIgnore}

- `; - return buildEmailHtml(subject, block, lang, undefined, true); -} - -/** - * Delivers a password-reset link. When SMTP is configured the user - * receives an email. When it isn't, the link is logged to stdout in a - * clearly-fenced block so the self-hosting admin can hand it off by - * other means. In both cases the caller always gets a boolean that - * indicates only whether the caller should treat delivery as - * best-effort done — the API response to the user must NOT leak it. - */ -export async function sendPasswordResetEmail( - to: string, - resetUrl: string, - userId: number | null, -): Promise<{ delivered: 'email' | 'log' | 'failed' }> { - const lang = userId ? getUserLanguage(userId) : 'en'; - const strings = PASSWORD_RESET_I18N[lang] || PASSWORD_RESET_I18N.en; - const smtpCfg = getSmtpConfig(); - - if (!smtpCfg) { - // No SMTP configured — log the link in a visually distinct block so - // the admin can relay it. Never log the associated user id/email - // content at a lower level, only what's needed. - - console.log( - `\n===== PASSWORD RESET LINK =====\n` + - `to: ${to}\n` + - `url: ${resetUrl}\n` + - `expires: 60 minutes\n` + - `(SMTP is not configured — deliver this link to the user manually.)\n` + - `================================\n`, - ); - logInfo(`Password reset link issued (no SMTP) for=${to}`); - return { delivered: 'log' }; - } - - try { - const skipTls = readEnv().smtp.skipTlsVerify || getAppSetting('smtp_skip_tls_verify') === 'true'; - const transporter = nodemailer.createTransport({ - host: smtpCfg.host, - port: smtpCfg.port, - secure: smtpCfg.secure, - auth: smtpCfg.user ? { user: smtpCfg.user, pass: smtpCfg.pass } : undefined, - ...(skipTls ? { tls: { rejectUnauthorized: false } } : {}), - }); - await transporter.sendMail({ - from: smtpCfg.from, - to, - subject: `TREK — ${strings.subject}`, - text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`, - html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang), - }); - logInfo(`Password reset email sent to=${to}`); - return { delivered: 'email' }; - } catch (err) { - logError(`Password reset email failed to=${to}: ${err instanceof Error ? err.message : err}`); - return { delivered: 'failed' }; - } -} - -export async function sendEmail( - to: string, - subject: string, - body: string, - userId?: number, - navigateTarget?: string, -): Promise { - const config = getSmtpConfig(); - if (!config) return false; - - const lang = userId ? getUserLanguage(userId) : 'en'; - - try { - const skipTls = readEnv().smtp.skipTlsVerify || getAppSetting('smtp_skip_tls_verify') === 'true'; - const transporter = nodemailer.createTransport({ - host: config.host, - port: config.port, - secure: config.secure, - auth: config.user ? { user: config.user, pass: config.pass } : undefined, - ...(skipTls ? { tls: { rejectUnauthorized: false } } : {}), - }); - - await transporter.sendMail({ - from: config.from, - to, - subject: `TREK — ${subject}`, - text: body, - html: buildEmailHtml(subject, body, lang, navigateTarget), - }); - logInfo(`Email sent to=${to} subject="${subject}"`); - logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`); - return true; - } catch (err) { - logError(`Email send failed to=${to}: ${err instanceof Error ? err.message : err}`); - return false; - } -} - -export function buildWebhookBody( - url: string, - payload: { event: string; title: string; body: string; tripName?: string; link?: string }, -): string { - const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url); - const isSlack = /hooks\.slack\.com\//.test(url); - - if (isDiscord) { - return JSON.stringify({ - embeds: [ - { - title: `📍 ${payload.title}`, - description: payload.body, - url: payload.link, - color: 0x3b82f6, - footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' }, - timestamp: new Date().toISOString(), - }, - ], - }); - } - - if (isSlack) { - const trip = payload.tripName ? ` • _${payload.tripName}_` : ''; - const link = payload.link ? `\n<${payload.link}|Open in TREK>` : ''; - return JSON.stringify({ - text: `*${payload.title}*\n${payload.body}${trip}${link}`, - }); - } - - return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }); -} - -export async function sendWebhook( - url: string, - payload: { event: string; title: string; body: string; tripName?: string; link?: string }, -): Promise { - if (!url) return false; - - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) { - logError(`Webhook blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`); - return false; - } - - try { - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: buildWebhookBody(url, payload), - signal: AbortSignal.timeout(10000), - dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), - } as any); - - if (!res.ok) { - const errBody = await res.text().catch(() => ''); - logError(`Webhook HTTP ${res.status}: ${errBody}`); - return false; - } - - logInfo(`Webhook sent event=${payload.event} trip=${payload.tripName || '-'}`); - logDebug(`Webhook url=${url} payload=${buildWebhookBody(url, payload).substring(0, 500)}`); - return true; - } catch (err) { - logError(`Webhook failed event=${payload.event}: ${err instanceof Error ? err.message : err}`); - return false; - } -} - -export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> { - if (!getSmtpConfig()) return { success: false, error: 'SMTP not configured' }; - try { - const config = getSmtpConfig()!; - const skipTls = readEnv().smtp.skipTlsVerify || getAppSetting('smtp_skip_tls_verify') === 'true'; - const transporter = nodemailer.createTransport({ - host: config.host, - port: config.port, - secure: config.secure, - auth: config.user ? { user: config.user, pass: config.pass } : undefined, - ...(skipTls ? { tls: { rejectUnauthorized: false } } : {}), - }); - await transporter.sendMail({ - from: config.from, - to, - subject: 'TREK — Test Notification', - text: 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.', - }); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; - } -} - -export async function testWebhook(url: string): Promise<{ success: boolean; error?: string }> { - try { - const sent = await sendWebhook(url, { - event: 'test', - title: 'Test Notification', - body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.', - }); - return sent ? { success: true } : { success: false, error: 'Failed to send webhook' }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; - } -} - -// ── Ntfy ────────────────────────────────────────────────────────────────── - -export interface NtfyConfig { - server: string | null; - topic: string | null; - token: string | null; -} - -/** Priority and tags mapped to each notification event type. */ -const NTFY_EVENT_META: Partial> = { - trip_invite: { priority: 4, tags: ['loudspeaker'] }, - booking_change: { priority: 3, tags: ['calendar'] }, - trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] }, - vacay_invite: { priority: 4, tags: ['palm_tree'] }, - photos_shared: { priority: 3, tags: ['camera'] }, - collab_message: { priority: 3, tags: ['speech_balloon'] }, - packing_tagged: { priority: 3, tags: ['luggage'] }, - version_available: { priority: 4, tags: ['package'] }, - synology_session_cleared: { priority: 3, tags: ['warning'] }, -}; -const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] }; - -export function getUserNtfyConfig(userId: number): NtfyConfig | null { - const rows = db - .prepare("SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')") - .all(userId) as { key: string; value: string }[]; - if (rows.length === 0) return null; - const map: Record = {}; - for (const r of rows) map[r.key] = r.value; - return { - topic: map['ntfy_topic'] || null, - server: map['ntfy_server'] || null, - token: map['ntfy_token'] ? decrypt_api_key(map['ntfy_token']) : null, - }; -} - -export function getAdminNtfyConfig(): NtfyConfig { - const topic = getAppSetting('admin_ntfy_topic') || null; - const server = getAppSetting('admin_ntfy_server') || null; - const rawToken = getAppSetting('admin_ntfy_token') || null; - return { - topic, - server, - token: rawToken ? decrypt_api_key(rawToken) : null, - }; -} - -/** - * Resolve the ntfy POST URL for a per-user send. The topic must come from the - * user's own config — the admin topic is reserved for admin-scoped sends - * (see resolveAdminNtfyUrl). Only the server falls back to the admin default. - * Returns null if the user has no topic. - */ -export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null { - const topic = userCfg?.topic; - if (!topic) return null; - const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, ''); - return `${base}/${encodeURIComponent(topic)}`; -} - -/** Resolve the ntfy POST URL for admin-scoped sends. Returns null if no admin topic. */ -export function resolveAdminNtfyUrl(adminCfg: NtfyConfig): string | null { - if (!adminCfg.topic) return null; - const base = (adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, ''); - return `${base}/${encodeURIComponent(adminCfg.topic)}`; -} - -function encodeHeaderValue(value: string): string { - for (let i = 0; i < value.length; i++) { - if (value.charCodeAt(i) > 0x7f) { - return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`; - } - } - return value; -} - -export async function sendNtfy( - url: string, - token: string | null, - payload: { event: string; title: string; body: string; link?: string }, -): Promise { - if (!url) return false; - - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) { - logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`); - return false; - } - - const meta = NTFY_EVENT_META[payload.event as NotifEventType] ?? NTFY_DEFAULT_META; - - // ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers - const headers: Record = { - Title: encodeHeaderValue(payload.title), - Priority: String(meta.priority), - }; - if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(','); - if (payload.link) headers['Click'] = encodeHeaderValue(payload.link); - if (token) headers['Authorization'] = `Bearer ${token}`; - - try { - const res = await fetch(url, { - method: 'POST', - headers, - body: payload.body, - signal: AbortSignal.timeout(10000), - dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), - } as any); - - if (!res.ok) { - const errBody = await res.text().catch(() => ''); - logError(`Ntfy HTTP ${res.status}: ${errBody}`); - return false; - } - - logInfo(`Ntfy sent event=${payload.event}`); - logDebug(`Ntfy url=${url} priority=${meta.priority} tags=${meta.tags.join(',')}`); - return true; - } catch (err) { - logError(`Ntfy failed event=${payload.event}: ${err instanceof Error ? err.message : err}`); - return false; - } -} - -export async function testNtfy(cfg: { - topic: string; - server?: string | null; - token?: string | null; -}): Promise<{ success: boolean; error?: string }> { - const adminCfg = getAdminNtfyConfig(); - const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null }); - if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' }; - try { - const sent = await sendNtfy(url, cfg.token ?? null, { - event: 'test', - title: 'Test Notification', - body: 'This is a test notification from TREK. If you received this, your ntfy configuration is working correctly.', - }); - return sent ? { success: true } : { success: false, error: 'Failed to send ntfy notification' }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; - } -} diff --git a/server/src/services/notifications/builtins.ts b/server/src/services/notifications/builtins.ts deleted file mode 100644 index 17c96024b..000000000 --- a/server/src/services/notifications/builtins.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - sendEmail, - sendWebhook, - sendNtfy, - testSmtp, - testWebhook, - testNtfy, - isSmtpConfigured, - getUserEmail, - getUserWebhookUrl, - getAdminWebhookUrl, - getUserNtfyConfig, - getAdminNtfyConfig, - resolveNtfyUrl, - resolveAdminNtfyUrl, -} from '../notifications'; -import { registerChannel, type ChannelMessage, type ExternalChannel } from './channelRegistry'; - -// The three built-in external channels, wrapping the send functions and config -// resolvers that already live in ../notifications.ts. No delivery logic is -// rewritten here — it is only relocated behind the ExternalChannel interface so -// notificationService.send() can iterate instead of branching. - -/** - * Every event except `synology_session_cleared` is deliverable externally. - * This is the pre-registry IMPLEMENTED_COMBOS table, transposed: it listed - * ['inapp','email','webhook','ntfy'] for every event and ['inapp'] for that one. - */ -function supportsAllButSynology(event: string): boolean { - return event !== 'synology_session_cleared'; -} - -const emailChannel: ExternalChannel = { - id: 'email', - source: 'builtin', - labelKey: 'settings.notificationPreferences.email', - // Admin-scoped events (version_available) reach admins by email even when the - // admin has not put `email` in notification_channels — the admin global pref - // gates it instead. Verbatim from the pre-registry dispatch. - bypassesActiveToggleForAdminEvents: true, - supportsEvent: supportsAllButSynology, - isInstanceConfigured: isSmtpConfigured, - isConfiguredFor: (userId) => !!getUserEmail(userId), - async sendToUser(userId, msg) { - const email = getUserEmail(userId); - if (!email) return false; - return sendEmail(email, msg.title, msg.body, userId, msg.navigateTarget); - }, - async test(userId) { - const email = getUserEmail(userId); - if (!email) return { success: false, error: 'No email address on file' }; - return testSmtp(email); - }, -}; - -const webhookChannel: ExternalChannel = { - id: 'webhook', - source: 'builtin', - labelKey: 'settings.notificationPreferences.webhook', - supportsAdminGlobal: true, - supportsEvent: supportsAllButSynology, - isConfiguredFor: (userId) => !!getUserWebhookUrl(userId), - async sendToUser(userId, msg) { - const url = getUserWebhookUrl(userId); - if (!url) return false; - return sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, tripName: msg.tripName, link: msg.url }); - }, - async sendGlobal(msg: ChannelMessage) { - const url = getAdminWebhookUrl(); - if (!url) return false; - return sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, link: msg.url }); - }, - async test(userId, override) { - const url = (typeof override?.url === 'string' && override.url) || getUserWebhookUrl(userId); - if (!url) return { success: false, error: 'No webhook URL configured' }; - return testWebhook(url); - }, -}; - -const ntfyChannel: ExternalChannel = { - id: 'ntfy', - source: 'builtin', - labelKey: 'settings.notificationPreferences.ntfy', - supportsAdminGlobal: true, - supportsEvent: supportsAllButSynology, - isConfiguredFor: (userId) => !!resolveNtfyUrl(getAdminNtfyConfig(), getUserNtfyConfig(userId)), - async sendToUser(userId, msg) { - const userCfg = getUserNtfyConfig(userId); - const adminCfg = getAdminNtfyConfig(); - const url = resolveNtfyUrl(adminCfg, userCfg); - if (!url) return false; - return sendNtfy(url, userCfg?.token ?? adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url }); - }, - async sendGlobal(msg: ChannelMessage) { - const adminCfg = getAdminNtfyConfig(); - const url = resolveAdminNtfyUrl(adminCfg); - if (!url) return false; - return sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url }); - }, - async test(userId, override) { - const topic = typeof override?.topic === 'string' ? override.topic : getUserNtfyConfig(userId)?.topic; - if (!topic) return { success: false, error: 'Could not resolve ntfy URL — missing topic' }; - return testNtfy({ - topic, - server: typeof override?.server === 'string' ? override.server : null, - token: typeof override?.token === 'string' ? override.token : null, - }); - }, -}; - -export const BUILTIN_CHANNELS = [emailChannel, webhookChannel, ntfyChannel]; - -/** Idempotent — safe to call from every entry point that needs the registry populated. */ -export function registerBuiltinChannels(): void { - for (const channel of BUILTIN_CHANNELS) registerChannel(channel); -} - -registerBuiltinChannels(); diff --git a/server/tests/e2e/admin.e2e.test.ts b/server/tests/e2e/admin.e2e.test.ts index 10e8246dd..ffd90cf1f 100644 --- a/server/tests/e2e/admin.e2e.test.ts +++ b/server/tests/e2e/admin.e2e.test.ts @@ -73,7 +73,15 @@ vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: ( vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../src/mcp', () => ({ invalidateMcpSessions: vi.fn() })); vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn() })); -vi.mock('../../src/services/notificationPreferencesService', () => ({ getPreferencesMatrix: vi.fn(() => ({})), setAdminPreferences: vi.fn() })); +// Preferences are a provider since the notifications fold; stub the two methods +// the admin surface calls on the prototype, so AdminService still resolves it +// through DI. +vi.mock('../../src/nest/notifications/notification-preferences.service', async (importOriginal) => { + const actual = await importOriginal(); + actual.NotificationPreferencesService.prototype.getPreferencesMatrix = vi.fn(() => ({}) as never); + actual.NotificationPreferencesService.prototype.setAdminPreferences = vi.fn(); + return actual; +}); vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) })); import { AdminModule } from '../../src/nest/admin/admin.module'; diff --git a/server/tests/e2e/auth.e2e.test.ts b/server/tests/e2e/auth.e2e.test.ts index b2620c493..05b99b53f 100644 --- a/server/tests/e2e/auth.e2e.test.ts +++ b/server/tests/e2e/auth.e2e.test.ts @@ -36,12 +36,12 @@ vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi. // The audit domain is DI-native: writeAudit runs for real against the temp // db's audit_log table; only the file logger is silenced. vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); -vi.mock('../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: true }) })); vi.mock('../../src/app-config', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getAppUrl: () => 'https://x' }; }); +import { MailerService } from '../../src/nest/notifications/mailer/mailer.service'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { createUser } from '../helpers/factories'; @@ -58,7 +58,12 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp let userPassword: string; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] }) + // The mailer is a provider since the notifications fold; overriding it is + // the DI-native replacement for the old services/notifications module mock. + .overrideProvider(MailerService) + .useValue({ sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: 'email' }) }) + .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/notifications.e2e.test.ts b/server/tests/e2e/notifications.e2e.test.ts index 8b874b902..13f13a3ce 100644 --- a/server/tests/e2e/notifications.e2e.test.ts +++ b/server/tests/e2e/notifications.e2e.test.ts @@ -49,22 +49,23 @@ const { db } = vi.hoisted(() => { vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); -// The preference matrix and the channel transports stay plain services/* -// modules after the fold — mock them; the in-app store is real SQL now. -const { prefs, channels } = vi.hoisted(() => ({ +// The preference matrix and the channel transports are providers since the +// fold — overridden at the container instead of mocked by module path; the +// in-app store is real SQL. +const { prefs, mailer, webhook, ntfy } = vi.hoisted(() => ({ prefs: { getPreferencesMatrix: vi.fn(), setPreferences: vi.fn() }, - channels: { - testSmtp: vi.fn(), testWebhook: vi.fn(), testNtfy: vi.fn(), - getUserWebhookUrl: vi.fn(), getAdminWebhookUrl: vi.fn(), - getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn(), - }, + mailer: { testSmtp: vi.fn(), isSmtpConfigured: vi.fn(() => true), getUserEmail: vi.fn(), getUserLanguage: vi.fn(() => 'en') }, + webhook: { testWebhook: vi.fn(), getUserWebhookUrl: vi.fn(), getAdminWebhookUrl: vi.fn() }, + ntfy: { testNtfy: vi.fn(), getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn() }, })); -vi.mock('../../src/services/notificationPreferencesService', () => prefs); -vi.mock('../../src/services/notifications', () => channels); import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { NotificationsModule } from '../../src/nest/notifications/notifications.module'; +import { MailerService } from '../../src/nest/notifications/mailer/mailer.service'; +import { NotificationPreferencesService } from '../../src/nest/notifications/notification-preferences.service'; +import { NtfyService } from '../../src/nest/notifications/transports/ntfy.service'; +import { WebhookService } from '../../src/nest/notifications/transports/webhook.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; function seedNotification(recipientId: number, overrides: { is_read?: number } = {}): number { @@ -82,7 +83,12 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => { async function build() { const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, NotificationsModule], - }).compile(); + }) + .overrideProvider(NotificationPreferencesService).useValue(prefs) + .overrideProvider(MailerService).useValue(mailer) + .overrideProvider(WebhookService).useValue(webhook) + .overrideProvider(NtfyService).useValue(ntfy) + .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); @@ -96,7 +102,7 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => { app = await build(); server = app.getHttpServer(); prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} }); - channels.testSmtp.mockResolvedValue({ success: true }); + mailer.testSmtp.mockResolvedValue({ success: true }); }); afterAll(async () => { @@ -118,7 +124,7 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => { const res = await request(server).post('/api/notifications/test-smtp').set('Cookie', sessionCookie(2)).send({}); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'Admin only' }); - expect(channels.testSmtp).not.toHaveBeenCalled(); + expect(mailer.testSmtp).not.toHaveBeenCalled(); }); it('200 test-smtp for an admin (stays 200, not 201)', async () => { diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index ca0a6ebd4..fba1bb932 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -47,6 +47,7 @@ import { VacayMcp } from '../../src/nest/vacay/vacay.mcp'; import { VacayService } from '../../src/nest/vacay/vacay.service'; import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service'; +import { makeNotificationsService, makeNotificationPreferencesService } from './notifications'; /** * Hand-wired counterpart of the boot-time discovery in McpRegistryService, @@ -104,7 +105,7 @@ export function createMcpTestRegistry(): McpRegistry { new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService), dbService, authService), new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService), new AtlasMcp(new AtlasService(dbService)), - new NotificationsMcp(new NotificationsService(dbService, realtimeService), authService), + new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService), ], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, ); diff --git a/server/tests/helpers/notifications.ts b/server/tests/helpers/notifications.ts new file mode 100644 index 000000000..4991fbf45 --- /dev/null +++ b/server/tests/helpers/notifications.ts @@ -0,0 +1,31 @@ +import { DatabaseService } from '../../src/nest/database/database.service'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; +import { MailerService } from '../../src/nest/notifications/mailer/mailer.service'; +import { NotificationPreferencesService } from '../../src/nest/notifications/notification-preferences.service'; +import { NotificationsService } from '../../src/nest/notifications/notifications.service'; +import { NtfyService } from '../../src/nest/notifications/transports/ntfy.service'; +import { WebhookService } from '../../src/nest/notifications/transports/webhook.service'; + +/** + * A NotificationsService wired the way Nest wires it. + * + * The domain takes six providers since the fold, and eight places used to build + * it by hand — every added constructor parameter was an eight-file diff. One + * helper keeps that at one. + */ +export function makeNotificationsService(dbs: DatabaseService, realtime = new RealtimeService()): NotificationsService { + const mailer = new MailerService(dbs); + return new NotificationsService( + dbs, + realtime, + mailer, + new WebhookService(dbs), + new NtfyService(dbs), + new NotificationPreferencesService(dbs, mailer), + ); +} + +/** The preferences half on its own, over the same connection. */ +export function makeNotificationPreferencesService(dbs: DatabaseService): NotificationPreferencesService { + return new NotificationPreferencesService(dbs, new MailerService(dbs)); +} diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 185802a9c..6a462e3d4 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -29,6 +29,7 @@ import { AuditService } from '../../src/nest/audit/audit.service'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service'; +import { makeNotificationsService, makeNotificationPreferencesService } from './notifications'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -75,7 +76,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto places, new CollectionsService(dbs, permissions, realtime), new AtlasService(dbs), - new NotificationsService(dbs, realtime), + makeNotificationsService(dbs, realtime), ); } diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts index 0a4698521..5006b9294 100644 --- a/server/tests/integration/notifications.test.ts +++ b/server/tests/integration/notifications.test.ts @@ -45,13 +45,18 @@ vi.mock('../../src/config', () => ({ DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -vi.mock('../../src/services/notifications', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - testSmtp: vi.fn().mockResolvedValue({ success: true }), - testWebhook: vi.fn().mockResolvedValue({ success: true }), - }; +// The two test-send endpoints must not reach a real SMTP server or URL. Since +// the fold they are provider methods, so they are stubbed on the prototype +// rather than by module path — everything else in the domain stays real. +vi.mock('../../src/nest/notifications/mailer/mailer.service', async (importOriginal) => { + const actual = await importOriginal(); + actual.MailerService.prototype.testSmtp = vi.fn().mockResolvedValue({ success: true }); + return actual; +}); +vi.mock('../../src/nest/notifications/transports/webhook.service', async (importOriginal) => { + const actual = await importOriginal(); + actual.WebhookService.prototype.testWebhook = vi.fn().mockResolvedValue({ success: true }); + return actual; }); import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index e6fc77b41..002ff0564 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -72,6 +72,7 @@ import { NotificationsService } from '../../../src/nest/notifications/notificati import { AdminService } from '../../../src/nest/admin/admin.service'; import { checkAndNotifyVersion as bridgeCheckAndNotifyVersion } from '../../../src/nest/admin/admin.bridge'; import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers'; +import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); @@ -87,8 +88,9 @@ const svc = new AdminService( new PackingService(dbs, permissions, realtime), auth, permissions, - new NotificationsService(dbs, realtime), + makeNotificationsService(dbs, realtime), userCleanup, + makeNotificationPreferencesService(dbs), ); // Legacy free-function names bound to the service, so the moved cases below read diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index d24aea36c..7b33f664e 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -55,6 +55,7 @@ import { PackingService } from '../../../src/nest/packing/packing.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { NotificationsService } from '../../../src/nest/notifications/notifications.service'; import { AdminService } from '../../../src/nest/admin/admin.service'; +import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); @@ -70,8 +71,9 @@ const svc = new AdminService( new PackingService(dbs, permissions, realtime), auth, permissions, - new NotificationsService(dbs, realtime), + makeNotificationsService(dbs, realtime), userCleanup, + makeNotificationPreferencesService(dbs), ); const checkAndNotifyVersion = () => svc.checkAndNotifyVersion(); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 0eca17696..47de3d991 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -54,7 +54,6 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ encrypt_api_key: vi.fn((v) => v), })); vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() })); -vi.mock('../../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn() })); vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), @@ -85,6 +84,11 @@ import { TripMembershipService } from '../../../src/nest/trip-membership/trip-me import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service'; import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service'; import { revokeUserSessions } from '../../../src/mcp/sessionManager'; +import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; + +// MailerService is injected since the notifications fold — a stub instead of a +// module mock. sendPasswordResetEmail is the only thing auth reaches for. +const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService; // Stubbed rather than real: these tests assert that the invite path CALLS the // join, not what the join writes (TRIP-JOIN-* cover that). @@ -97,6 +101,7 @@ const svc = new AuthService( membershipStub, new WebauthnConfigService(new DatabaseService(testDb)), new UserCleanupService(new DatabaseService(testDb)), + mailerStub, ); // --------------------------------------------------------------------------- diff --git a/server/tests/unit/services/channelOverride.test.ts b/server/tests/unit/nest/channel-override.test.ts similarity index 75% rename from server/tests/unit/services/channelOverride.test.ts rename to server/tests/unit/nest/channel-override.test.ts index 71092dbf8..a74ab53f7 100644 --- a/server/tests/unit/services/channelOverride.test.ts +++ b/server/tests/unit/nest/channel-override.test.ts @@ -30,14 +30,25 @@ import { createUser, createAdmin, setAppSetting, setNotificationChannels } from // The send dispatcher is DI-native since the notifications fold; drive it // through the same outside-container entry point the scheduler uses. import { send } from '../../../src/nest/notifications/notifications.bridge'; -import { getPreferencesMatrix } from '../../../src/services/notificationPreferencesService'; +import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service'; +import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { createPluginRuntime } from '../../helpers/plugin-host'; +import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; +import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service'; + import { setPluginChannelSource, listChannels, getChannel, registerChannel, type ExternalChannel, -} from '../../../src/services/notifications/channelRegistry'; +} from '../../../src/nest/notifications/channel-registry'; + +const prefsDbs = new DatabaseService(testDb); +const prefsSvc = new NotificationPreferencesService(prefsDbs, new MailerService(prefsDbs)); +const getPreferencesMatrix = prefsSvc.getPreferencesMatrix.bind(prefsSvc); +void WebhookService; void NtfyService; beforeAll(() => { createTables(testDb); runMigrations(testDb); }); beforeEach(() => { resetTestDb(testDb); setPluginChannelSource(null); }); @@ -193,3 +204,54 @@ describe('a live plugin channel needs no second opt-in', () => { expect(rogueSend).not.toHaveBeenCalled(); // no credentials → nothing to send with }); }); + +/** + * The seam itself, end to end. + * + * PluginRuntimeService pushes its channel getter into the registry at + * onModuleInit; notifications.bridge.ts constructs its own NotificationsService + * for the scheduler crons and the memories senders. Both only meet because the + * registry is a module singleton. Make it a provider and the bridge gets an + * empty one — plugin channels then go quiet with no error anywhere, which is + * exactly the kind of failure nothing else here would catch. Every case above + * sets the source by hand; this one goes through the runtime. + */ +describe('the plugin channel source reaches the bridge instance', () => { + it('CHOVR-015 — a channel the runtime publishes is delivered by notifications.bridge.send', async () => { + const { user } = createUser(testDb, { username: 'recipient' }); + setNotificationChannels(testDb, 'none'); + const delivered: Array<{ userId: number; title: string }> = []; + + const runtime = createPluginRuntime(new DatabaseService(testDb)); + // Stand in for a booted supervisor: one plugin providing the hook, and an + // invokeHook that records instead of forking a child. + Object.defineProperty(runtime, 'supervisor', { + value: { providersOf: (hook: string) => (hook === 'notificationChannel' ? ['gotify'] : []) }, + configurable: true, + }); + (runtime as unknown as { invokeHook: unknown }).invokeHook = async ( + _id: string, + _hook: string, + _method: string, + args: unknown[], + ) => { + const msg = args[0] as { title: string }; + delivered.push({ userId: user.id, title: msg.title }); + return true; + }; + testDb.prepare("INSERT INTO plugins (id, name, version, status, enabled) VALUES ('gotify', 'Gotify', '1.0.0', 'active', 1)").run(); + + // The exact line plugin-runtime.service.ts runs in onModuleInit. + setPluginChannelSource(() => runtime.notificationChannels()); + + try { + expect(getChannel('plugin:gotify')).toBeDefined(); + + await send({ ...TRIP_INVITE, targetId: user.id }); + + expect(delivered).toHaveLength(1); + } finally { + setPluginChannelSource(null); + } + }); +}); diff --git a/server/tests/unit/services/channelRegistry.test.ts b/server/tests/unit/nest/channel-registry.test.ts similarity index 80% rename from server/tests/unit/services/channelRegistry.test.ts rename to server/tests/unit/nest/channel-registry.test.ts index 636e211ab..40f870a93 100644 --- a/server/tests/unit/services/channelRegistry.test.ts +++ b/server/tests/unit/nest/channel-registry.test.ts @@ -13,8 +13,20 @@ import { __resetChannelsForTest, type ChannelMessage, type ExternalChannel, -} from '../../../src/services/notifications/channelRegistry'; -import { registerBuiltinChannels } from '../../../src/services/notifications/builtins'; +} from '../../../src/nest/notifications/channel-registry'; +import { registerBuiltinChannels } from '../../../src/nest/notifications/channels/builtins'; +import type { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; +import type { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service'; +import type { WebhookService } from '../../../src/nest/notifications/transports/webhook.service'; + +// The built-ins take their transports as an argument now; these cases only care +// that the three ids land in the registry with the right privileges, so the +// transports are stubs. +const stubTransports = { + mailer: { isSmtpConfigured: () => true, getUserEmail: () => null } as unknown as MailerService, + webhook: { getUserWebhookUrl: () => null, getAdminWebhookUrl: () => null } as unknown as WebhookService, + ntfy: { getUserNtfyConfig: () => null, getAdminNtfyConfig: () => ({ server: null, topic: null, token: null }) } as unknown as NtfyService, +}; function fakeChannel(id: string, over: Partial = {}): ExternalChannel { return { @@ -32,7 +44,7 @@ const MSG: ChannelMessage = { event: 'trip_invite', title: 't', body: 'b' }; beforeEach(() => { __resetChannelsForTest(); - registerBuiltinChannels(); + registerBuiltinChannels(stubTransports); }); afterEach(() => { diff --git a/server/tests/unit/services/notificationPreferencesService.test.ts b/server/tests/unit/nest/notification-preferences.service.test.ts similarity index 91% rename from server/tests/unit/services/notificationPreferencesService.test.ts rename to server/tests/unit/nest/notification-preferences.service.test.ts index 51d591b45..95fa97483 100644 --- a/server/tests/unit/services/notificationPreferencesService.test.ts +++ b/server/tests/unit/nest/notification-preferences.service.test.ts @@ -36,16 +36,29 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories'; -import { - isEnabledForEvent, - getPreferencesMatrix, - setPreferences, - setAdminPreferences, - getAdminGlobalPref, - getActiveChannels, - isSmtpConfigured, - isWebhookConfigured, -} from '../../../src/services/notificationPreferencesService'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; +import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service'; +import { registerBuiltinChannels } from '../../../src/nest/notifications/channels/builtins'; +import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service'; +import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service'; +import { __resetChannelsForTest } from '../../../src/nest/notifications/channel-registry'; + +const dbs = new DatabaseService(testDb); +const mailer = new MailerService(dbs); +registerBuiltinChannels({ mailer, webhook: new WebhookService(dbs), ntfy: new NtfyService(dbs) }); +const svc = new NotificationPreferencesService(dbs, mailer); + +// Legacy free-function names bound to the service, so the moved cases read as before. +const isEnabledForEvent = svc.isEnabledForEvent.bind(svc); +const getPreferencesMatrix = svc.getPreferencesMatrix.bind(svc); +const setPreferences = svc.setPreferences.bind(svc); +const setAdminPreferences = svc.setAdminPreferences.bind(svc); +const getAdminGlobalPref = svc.getAdminGlobalPref.bind(svc); +const getActiveChannels = svc.getActiveChannels.bind(svc); +const isSmtpConfigured = svc.isSmtpConfigured.bind(svc); +const isWebhookConfigured = svc.isWebhookConfigured.bind(svc); + beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/services/notifications.test.ts b/server/tests/unit/nest/notification-transports.test.ts similarity index 95% rename from server/tests/unit/services/notifications.test.ts rename to server/tests/unit/nest/notification-transports.test.ts index 716597c5d..501782542 100644 --- a/server/tests/unit/services/notifications.test.ts +++ b/server/tests/unit/nest/notification-transports.test.ts @@ -24,7 +24,18 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({ createPinnedDispatcher: vi.fn(() => ({})), })); -import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook, sendNtfy, resolveNtfyUrl, resolveAdminNtfyUrl, type NtfyConfig } from '../../../src/services/notifications'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { getEventText, buildEmailHtml } from '../../../src/nest/notifications/mailer/email-html'; +import { WebhookService, buildWebhookBody } from '../../../src/nest/notifications/transports/webhook.service'; +import { NtfyService, resolveNtfyUrl, resolveAdminNtfyUrl, type NtfyConfig } from '../../../src/nest/notifications/transports/ntfy.service'; + +// The transports are providers now; the db stub below is the same one the +// module-level `db` mock used to supply, handed in instead of imported. +const stubDb = { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) } as unknown as ConstructorParameters[0]; +const webhookSvc = new WebhookService(new DatabaseService(stubDb)); +const ntfySvc = new NtfyService(new DatabaseService(stubDb)); +const sendWebhook = webhookSvc.sendWebhook.bind(webhookSvc); +const sendNtfy = ntfySvc.sendNtfy.bind(ntfySvc); import { checkSsrf } from '../../../src/utils/ssrfGuard'; import { logError } from '../../../src/nest/audit/audit-log.logger'; diff --git a/server/tests/unit/nest/notifications.inapp-prefs.test.ts b/server/tests/unit/nest/notifications.inapp-prefs.test.ts index 4775aa420..2f223f44d 100644 --- a/server/tests/unit/nest/notifications.inapp-prefs.test.ts +++ b/server/tests/unit/nest/notifications.inapp-prefs.test.ts @@ -45,8 +45,9 @@ import { registerAction } from '../../../src/nest/notifications/in-app-actions'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { NotificationsService } from '../../../src/nest/notifications/notifications.service'; +import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; -const notifications = new NotificationsService(new DatabaseService(testDb), new RealtimeService()); +const notifications = makeNotificationsService(new DatabaseService(testDb)); const createNotification = notifications.createNotification.bind(notifications); const createNotificationForRecipient = notifications.createNotificationForRecipient.bind(notifications); const respondToBoolean = notifications.respond.bind(notifications); diff --git a/server/tests/unit/nest/notifications.service.test.ts b/server/tests/unit/nest/notifications.service.test.ts index fc455ba93..91da8b03a 100644 --- a/server/tests/unit/nest/notifications.service.test.ts +++ b/server/tests/unit/nest/notifications.service.test.ts @@ -77,9 +77,10 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { NotificationsService, type NotificationPayload } from '../../../src/nest/notifications/notifications.service'; import { send as bridgeSend } from '../../../src/nest/notifications/notifications.bridge'; -import { setPluginChannelSource, type ExternalChannel } from '../../../src/services/notifications/channelRegistry'; +import { setPluginChannelSource, type ExternalChannel } from '../../../src/nest/notifications/channel-registry'; +import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; -const notifications = new NotificationsService(new DatabaseService(testDb), new RealtimeService()); +const notifications = makeNotificationsService(new DatabaseService(testDb)); const send = (payload: NotificationPayload) => notifications.send(payload); // ── Helpers ──────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index e5c2f7786..53f73f949 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -62,7 +62,6 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ encrypt_api_key: vi.fn((v) => v), })); vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() })); -vi.mock('../../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn() })); vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), @@ -94,6 +93,11 @@ import { AtlasService } from '../../../src/nest/atlas/atlas.service'; import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; import { AuthService } from '../../../src/nest/auth/auth.service'; import { OidcService } from '../../../src/nest/oidc/oidc.service'; +import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; + +// MailerService is injected since the notifications fold — a stub instead of a +// module mock. sendPasswordResetEmail is the only thing auth reaches for. +const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService; const membership = new TripMembershipService(new DatabaseService(testDb)); const auth = new AuthService( @@ -101,6 +105,9 @@ const auth = new AuthService( new PermissionsService(new DatabaseService(testDb)), new AtlasService(new DatabaseService(testDb)), membership, + undefined as never, // webauthn — not reached from the OIDC paths + undefined as never, // userCleanup — not reached from the OIDC paths + mailerStub, ); const svc = new OidcService(new DatabaseService(testDb), auth, membership); diff --git a/server/tests/unit/nest/passkey.service.test.ts b/server/tests/unit/nest/passkey.service.test.ts index 8a6344f4d..6429bba3f 100644 --- a/server/tests/unit/nest/passkey.service.test.ts +++ b/server/tests/unit/nest/passkey.service.test.ts @@ -54,7 +54,6 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ encrypt_api_key: vi.fn((v) => v), })); vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() })); -vi.mock('../../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn() })); vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), @@ -86,6 +85,11 @@ import { AtlasService } from '../../../src/nest/atlas/atlas.service'; import { AuthService } from '../../../src/nest/auth/auth.service'; import { PasskeyService } from '../../../src/nest/auth/passkey.service'; import type { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service'; +import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; + +// MailerService is injected since the notifications fold — a stub instead of a +// module mock. sendPasswordResetEmail is the only thing auth reaches for. +const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService; // The RP config resolver has its own suite (webauthn-config.service.test.ts); // here it is a switch for the configured / not-configured branches, handed in @@ -97,6 +101,10 @@ const auth = new AuthService( new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new AtlasService(new DatabaseService(testDb)), + undefined as never, // membership — not reached from the passkey paths + undefined as never, // webauthn — PasskeyService gets its own stub below + undefined as never, // userCleanup — not reached from the passkey paths + mailerStub, ); const svc = new PasskeyService(new DatabaseService(testDb), auth, webauthn);