import nodemailer from 'nodemailer'; import { db } from '../db/database'; import { decrypt_api_key } from './apiKeyCrypto'; import { logInfo, logDebug, logError } from './auditLog'; import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard'; // ── Types ────────────────────────────────────────────────────────────────── import type { NotifEventType } from './notificationPreferencesService'; import { EMAIL_I18N as I18N, EVENT_TEXTS, PASSWORD_RESET_I18N } from '@trek/shared/i18n/externalNotifications'; import type { EmailStrings, EventText, PasswordResetStrings, NotificationEventKey } from '@trek/shared/i18n/externalNotifications'; // Compile-time guard: shared NotificationEventKey and server NotifEventType must stay in sync. type _EvtFwd = NotifEventType extends NotificationEventKey ? true : never type _EvtBwd = NotificationEventKey extends NotifEventType ? true : never const _eventKeyDriftGuard: [_EvtFwd, _EvtBwd] = [true, true] interface SmtpConfig { host: string; port: number; user: string; pass: string; from: string; secure: boolean; } // ── HTML escaping ────────────────────────────────────────────────────────── function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ── Settings helpers ─────────────────────────────────────────────────────── function getAppSetting(key: string): string | null { return (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; } function getSmtpConfig(): SmtpConfig | null { const host = process.env.SMTP_HOST || getAppSetting('smtp_host'); const port = process.env.SMTP_PORT || getAppSetting('smtp_port'); const user = process.env.SMTP_USER || getAppSetting('smtp_user'); const pass = process.env.SMTP_PASS || decrypt_api_key(getAppSetting('smtp_pass')) || ''; const from = process.env.SMTP_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 }; } // Exported for use by notificationService export function getAppUrl(): string { if (process.env.APP_URL) { try { const _ = new URL(process.env.APP_URL); return process.env.APP_URL.replace(/\/+$/, ''); } catch (_ignored) { } } const origins = process.env.ALLOWED_ORIGINS; if (origins) { const first = origins.split(',')[0]?.trim(); if (first) { try { const _ = new URL(first); return first.replace(/\/+$/, ''); } catch (_ignored) { } } } const port = Number(process.env.PORT) || 3001; return `http://localhost:${port}`; } /** Returns a URL guaranteed to satisfy the MCP SDK's issuer requirements (HTTPS or localhost). * Falls back to http://localhost:{PORT} when APP_URL/ALLOWED_ORIGINS use a non-HTTPS, non-localhost scheme * that would cause checkIssuerUrl to throw "Issuer URL must be HTTPS". */ export function getMcpSafeUrl(): string { const candidate = getAppUrl(); try { const u = new URL(candidate); if (u.protocol === 'https:' || u.hostname === 'localhost' || u.hostname === '127.0.0.1') { return candidate; } } catch { // candidate was somehow invalid — fall through to localhost } const port = Number(process.env.PORT) || 3001; return `http://localhost:${port}`; } export function getUserEmail(userId: number): string | null { return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null; } export function getUserLanguage(userId: number): string { return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en'; } export function getUserWebhookUrl(userId: number): string | null { const value = (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as { value: string } | undefined)?.value || null; return value ? decrypt_api_key(value) : null; } export function getAdminWebhookUrl(): string | null { const value = getAppSetting('admin_webhook_url') || null; return value ? decrypt_api_key(value) : null; } // ── Email i18n strings — imported from @trek/shared/i18n/externalNotifications ── // EVENT_TEXTS imported from @trek/shared/i18n/externalNotifications // Get localized event text export function getEventText(lang: string, event: NotifEventType, params: Record): EventText { const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en; const fn = texts[event] ?? EVENT_TEXTS.en[event]; if (!fn) return { title: event, body: '' }; return fn(params); } // ── Email HTML builder ───────────────────────────────────────────────────── export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string { const s = I18N[lang] || I18N.en; const appUrl = getAppUrl(); const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || '')); const safeSubject = escapeHtml(subject); const safeBody = rawBody ? body : escapeHtml(body); return `
${appUrl ? `` : ''}
TREK
TREK
Travel Resource & Exploration Kit

${safeSubject}

${safeBody}

${s.openTrek}

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

${s.madeWith} by Maurice · GitHub

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

${safeGreeting},

${safeBody}

${safeCta}

${safeExpiry}

${safeIgnore}

`; return buildEmailHtml(subject, block, lang, undefined, true); } /** * Delivers a password-reset link. When SMTP is configured the user * receives an email. When it isn't, the link is logged to stdout in a * clearly-fenced block so the self-hosting admin can hand it off by * other means. In both cases the caller always gets a boolean that * indicates only whether the caller should treat delivery as * best-effort done — the API response to the user must NOT leak it. */ export async function sendPasswordResetEmail( to: string, resetUrl: string, userId: number | null, ): Promise<{ delivered: 'email' | 'log' | 'failed' }> { const lang = userId ? getUserLanguage(userId) : 'en'; const strings = PASSWORD_RESET_I18N[lang] || PASSWORD_RESET_I18N.en; const smtpCfg = getSmtpConfig(); if (!smtpCfg) { // No SMTP configured — log the link in a visually distinct block so // the admin can relay it. Never log the associated user id/email // content at a lower level, only what's needed. // eslint-disable-next-line no-console 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 = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true'; const transporter = nodemailer.createTransport({ host: smtpCfg.host, port: smtpCfg.port, secure: smtpCfg.secure, auth: smtpCfg.user ? { user: smtpCfg.user, pass: smtpCfg.pass } : undefined, ...(skipTls ? { tls: { rejectUnauthorized: false } } : {}), }); await transporter.sendMail({ from: smtpCfg.from, to, subject: `TREK — ${strings.subject}`, text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`, html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang), }); logInfo(`Password reset email sent to=${to}`); return { delivered: 'email' }; } catch (err) { logError(`Password reset email failed to=${to}: ${err instanceof Error ? err.message : err}`); return { delivered: 'failed' }; } } export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise { const config = getSmtpConfig(); if (!config) return false; const lang = userId ? getUserLanguage(userId) : 'en'; try { const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true'; const transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: config.user ? { user: config.user, pass: config.pass } : undefined, ...(skipTls ? { tls: { rejectUnauthorized: false } } : {}), }); await transporter.sendMail({ from: config.from, to, subject: `TREK — ${subject}`, text: body, html: buildEmailHtml(subject, body, lang, navigateTarget), }); logInfo(`Email sent to=${to} subject="${subject}"`); logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`); return true; } catch (err) { logError(`Email send failed to=${to}: ${err instanceof Error ? err.message : err}`); return false; } } export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): string { const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url); const isSlack = /hooks\.slack\.com\//.test(url); if (isDiscord) { return JSON.stringify({ embeds: [{ title: `📍 ${payload.title}`, description: payload.body, url: payload.link, color: 0x3b82f6, footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' }, timestamp: new Date().toISOString(), }], }); } if (isSlack) { const trip = payload.tripName ? ` • _${payload.tripName}_` : ''; const link = payload.link ? `\n<${payload.link}|Open in TREK>` : ''; return JSON.stringify({ text: `*${payload.title}*\n${payload.body}${trip}${link}`, }); } return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }); } export async function sendWebhook(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): Promise { if (!url) return false; const ssrf = await checkSsrf(url); if (!ssrf.allowed) { logError(`Webhook blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`); return false; } try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: buildWebhookBody(url, payload), signal: AbortSignal.timeout(10000), dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), } as any); if (!res.ok) { const errBody = await res.text().catch(() => ''); logError(`Webhook HTTP ${res.status}: ${errBody}`); return false; } logInfo(`Webhook sent event=${payload.event} trip=${payload.tripName || '-'}`); logDebug(`Webhook url=${url} payload=${buildWebhookBody(url, payload).substring(0, 500)}`); return true; } catch (err) { logError(`Webhook failed event=${payload.event}: ${err instanceof Error ? err.message : err}`); return false; } } export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> { if (!getSmtpConfig()) return { success: false, error: 'SMTP not configured' }; try { const config = getSmtpConfig()!; const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true'; const transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: config.user ? { user: config.user, pass: config.pass } : undefined, ...(skipTls ? { tls: { rejectUnauthorized: false } } : {}), }); await transporter.sendMail({ from: config.from, to, subject: 'TREK — Test Notification', text: 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.', }); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; } } export async function testWebhook(url: string): Promise<{ success: boolean; error?: string }> { try { const sent = await sendWebhook(url, { event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' }); return sent ? { success: true } : { success: false, error: 'Failed to send webhook' }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; } } // ── Ntfy ────────────────────────────────────────────────────────────────── export interface NtfyConfig { server: string | null; topic: string | null; token: string | null; } /** Priority and tags mapped to each notification event type. */ const NTFY_EVENT_META: Partial> = { trip_invite: { priority: 4, tags: ['loudspeaker'] }, booking_change: { priority: 3, tags: ['calendar'] }, trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] }, vacay_invite: { priority: 4, tags: ['palm_tree'] }, photos_shared: { priority: 3, tags: ['camera'] }, collab_message: { priority: 3, tags: ['speech_balloon'] }, packing_tagged: { priority: 3, tags: ['luggage'] }, version_available: { priority: 4, tags: ['package'] }, synology_session_cleared: { priority: 3, tags: ['warning'] }, }; const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] }; export function getUserNtfyConfig(userId: number): NtfyConfig | null { const rows = db.prepare( "SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')" ).all(userId) as { key: string; value: string }[]; if (rows.length === 0) return null; const map: Record = {}; for (const r of rows) map[r.key] = r.value; return { topic: map['ntfy_topic'] || null, server: map['ntfy_server'] || null, token: map['ntfy_token'] ? decrypt_api_key(map['ntfy_token']) : null, }; } export function getAdminNtfyConfig(): NtfyConfig { const topic = getAppSetting('admin_ntfy_topic') || null; const server = getAppSetting('admin_ntfy_server') || null; const rawToken = getAppSetting('admin_ntfy_token') || null; return { topic, server, token: rawToken ? decrypt_api_key(rawToken) : null, }; } /** * Resolve the ntfy POST URL from admin base config + user override. * Returns null if topic cannot be determined. */ export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null { const topic = userCfg?.topic || adminCfg.topic; if (!topic) return null; const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, ''); return `${base}/${encodeURIComponent(topic)}`; } export function isNtfyConfiguredForUser(userId: number): boolean { const cfg = getUserNtfyConfig(userId); return !!(cfg?.topic); } export function isNtfyConfiguredAdmin(): boolean { return !!(getAppSetting('admin_ntfy_topic')); } function encodeHeaderValue(value: string): string { for (let i = 0; i < value.length; i++) { if (value.charCodeAt(i) > 0xFF) { return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`; } } return value; } export async function sendNtfy( url: string, token: string | null, payload: { event: string; title: string; body: string; link?: string }, ): Promise { if (!url) return false; const ssrf = await checkSsrf(url); if (!ssrf.allowed) { logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`); return false; } const meta = NTFY_EVENT_META[payload.event as NotifEventType] ?? NTFY_DEFAULT_META; // ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers const headers: Record = { 'Title': encodeHeaderValue(payload.title), 'Priority': String(meta.priority), }; if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(','); if (payload.link) headers['Click'] = encodeHeaderValue(payload.link); if (token) headers['Authorization'] = `Bearer ${token}`; try { const res = await fetch(url, { method: 'POST', headers, body: payload.body, signal: AbortSignal.timeout(10000), dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), } as any); if (!res.ok) { const errBody = await res.text().catch(() => ''); logError(`Ntfy HTTP ${res.status}: ${errBody}`); return false; } logInfo(`Ntfy sent event=${payload.event}`); logDebug(`Ntfy url=${url} priority=${meta.priority} tags=${meta.tags.join(',')}`); return true; } catch (err) { logError(`Ntfy failed event=${payload.event}: ${err instanceof Error ? err.message : err}`); return false; } } export async function testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise<{ success: boolean; error?: string }> { const adminCfg = getAdminNtfyConfig(); const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null }); if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' }; try { const sent = await sendNtfy(url, cfg.token ?? null, { event: 'test', title: 'Test Notification', body: 'This is a test notification from TREK. If you received this, your ntfy configuration is working correctly.', }); return sent ? { success: true } : { success: false, error: 'Failed to send ntfy notification' }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; } }