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.
This commit is contained in:
Maurice
2026-08-08 11:46:29 +02:00
committed by Maurice
parent 712a33626a
commit ac96644508
39 changed files with 1368 additions and 1095 deletions
+22 -5
View File
@@ -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
+9 -2
View File
@@ -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<void> {
+4 -3
View File
@@ -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<string, unknown>) { 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<typeof setAdminPreferences>[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<NotificationPreferencesService['setAdminPreferences']>[1]); }
}
+2
View File
@@ -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';
+5 -2
View File
@@ -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],
+3 -2
View File
@@ -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
@@ -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:';
@@ -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);
}
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Localized title/body for an event.
export function getEventText(lang: string, event: NotifEventType, params: Record<string, string>): 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 `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 40px 20px;">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
<!-- Header -->
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource &amp; Exploration Kit</div>
</td></tr>
<!-- Content -->
<tr><td style="padding: 32px 32px 16px;">
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${safeSubject}</h1>
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${safeBody}</p>
</td></tr>
<!-- CTA -->
${
appUrl
? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
</td></tr>`
: ''
}
<!-- Footer -->
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">&hearts;</span> by Maurice &middot; <a href="https://github.com/liketrek/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
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 = `
<p style="margin:0 0 16px 0; font-size:16px;">${safeGreeting},</p>
<p style="margin:0 0 20px 0; font-size:15px; line-height:1.6;">${safeBody}</p>
<p style="margin:28px 0;">
<a href="${resetUrl}" style="display:inline-block;padding:14px 28px;background:#111827;color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:15px;">${safeCta}</a>
</p>
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
`;
return buildEmailHtml(subject, block, lang, undefined, true);
}
@@ -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 {}
@@ -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<boolean> {
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' };
}
}
}
@@ -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<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
/** The columns to render, in order. Replaces the old fixed-shape available_channels. */
channels: ChannelDescriptor[];
event_types: NotifEventType[];
implemented_combos: Record<string, NotifChannel[]>;
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<string, NotifChannel[]> {
const out: Record<string, NotifChannel[]> = {};
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<string, boolean> = { 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<Record<string, Partial<Record<string, boolean>>>> = {};
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<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
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<Record<string, Partial<Record<string, boolean>>>>,
): 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<Record<string, Partial<Record<string, boolean>>>>
): 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<Record<string, Partial<Record<string, boolean>>>>
): void {
// Split global (email/webhook) from per-user (inapp) prefs
const globalPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
const userPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
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');
}
}
@@ -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<void> {
return notifications.send(payload);
@@ -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 {}
@@ -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<typeof getAdminNtfyConfig>;
type PreferencesMatrix = ReturnType<typeof getPreferencesMatrix>;
// 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<typeof setPreferences>[1]): void {
setPreferences(userId, body);
setPreferences(userId: number, body: Parameters<NotificationPreferencesService['setPreferences']>[1]): void {
this.prefs.setPreferences(userId, body);
}
testSmtp(to: string): Promise<ChannelTestResult> {
return testSmtp(to);
return this.mailer.testSmtp(to);
}
testWebhook(url: string): Promise<ChannelTestResult> {
return testWebhook(url);
return this.webhook.testWebhook(url);
}
testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise<ChannelTestResult> {
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<unknown>[] = [];
// ── 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);
@@ -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<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
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<string, string> = {};
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<boolean> {
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<string, string> = {
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' };
}
}
}
@@ -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<boolean> {
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' };
}
}
}
@@ -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';
@@ -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<string, NotifChannel[]> {
const out: Record<string, NotifChannel[]> = {};
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<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
/** The columns to render, in order. Replaces the old fixed-shape available_channels. */
channels: ChannelDescriptor[];
event_types: NotifEventType[];
implemented_combos: Record<string, NotifChannel[]>;
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<string, boolean> = { 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<Record<string, Partial<Record<string, boolean>>>> = {};
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<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
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<Record<string, Partial<Record<string, boolean>>>>,
upsert: Database.Statement<unknown[]>,
del: Database.Statement<unknown[]>
): 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<Record<string, Partial<Record<string, boolean>>>>
): 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<Record<string, Partial<Record<string, boolean>>>>
): 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<Record<string, Partial<Record<string, boolean>>>> = {};
const userPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
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');
}
-546
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── 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<string, string>): 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 `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 40px 20px;">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
<!-- Header -->
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource &amp; Exploration Kit</div>
</td></tr>
<!-- Content -->
<tr><td style="padding: 32px 32px 16px;">
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${safeSubject}</h1>
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${safeBody}</p>
</td></tr>
<!-- CTA -->
${
appUrl
? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
</td></tr>`
: ''
}
<!-- Footer -->
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">&hearts;</span> by Maurice &middot; <a href="https://github.com/liketrek/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
// ── 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 = `
<p style="margin:0 0 16px 0; font-size:16px;">${safeGreeting},</p>
<p style="margin:0 0 20px 0; font-size:15px; line-height:1.6;">${safeBody}</p>
<p style="margin:28px 0;">
<a href="${resetUrl}" style="display:inline-block;padding:14px 28px;background:#111827;color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:15px;">${safeCta}</a>
</p>
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
`;
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<boolean> {
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<boolean> {
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<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
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<string, string> = {};
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<boolean> {
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<string, string> = {
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' };
}
}
@@ -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();
+9 -1
View File
@@ -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<typeof import('../../src/nest/notifications/notification-preferences.service')>();
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';
+7 -2
View File
@@ -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<typeof import('../../src/app-config')>();
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());
+19 -13
View File
@@ -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 () => {
+2 -1
View File
@@ -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 },
);
+31
View File
@@ -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));
}
+2 -1
View File
@@ -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),
);
}
+12 -7
View File
@@ -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<typeof import('../../src/services/notifications')>();
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<typeof import('../../src/nest/notifications/mailer/mailer.service')>();
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<typeof import('../../src/nest/notifications/transports/webhook.service')>();
actual.WebhookService.prototype.testWebhook = vi.fn().mockResolvedValue({ success: true });
return actual;
});
import { buildApp } from '../../src/bootstrap';
+3 -1
View File
@@ -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
@@ -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();
+6 -1
View File
@@ -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,
);
// ---------------------------------------------------------------------------
@@ -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);
}
});
});
@@ -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> = {}): ExternalChannel {
return {
@@ -32,7 +44,7 @@ const MSG: ChannelMessage = { event: 'trip_invite', title: 't', body: 'b' };
beforeEach(() => {
__resetChannelsForTest();
registerBuiltinChannels();
registerBuiltinChannels(stubTransports);
});
afterEach(() => {
@@ -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);
@@ -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<typeof DatabaseService>[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';
@@ -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);
@@ -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 ────────────────────────────────────────────────────────────────
+8 -1
View File
@@ -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);
@@ -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);