import nodemailer from 'nodemailer'; import { getEnabledNotificationSettings, getEnabledEnvironmentNotifications, getEnvironment, type NotificationSettingData, type SmtpConfig, type AppriseConfig, type NotificationEventType } from './db'; import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers'; /** Drain a response body to release the underlying socket/TLS connection. */ async function drainResponse(response: Response): Promise { if (!response.bodyUsed) { try { await response.arrayBuffer(); } catch {} } } export interface NotificationPayload { title: string; message: string; type?: 'info' | 'success' | 'warning' | 'error'; environmentId?: number; environmentName?: string; } // Result type for functions that can return detailed errors export interface NotificationResult { success: boolean; error?: string; } // Send notification via SMTP async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise { try { const transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: config.username ? { user: config.username, pass: config.password } : undefined, tls: config.skipTlsVerify ? { rejectUnauthorized: false } : undefined }); const envBadge = payload.environmentName ? `${payload.environmentName}` : ''; const envText = payload.environmentName ? ` [${payload.environmentName}]` : ''; const html = `

${payload.title}${envBadge}

${payload.message}


Sent by Dockhand

`; await transporter.sendMail({ from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email, to: config.to_emails.join(', '), subject: `[Dockhand]${envText} ${payload.title}`, text: `${payload.title}${envText}\n\n${payload.message}`, html }); return { success: true }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); return { success: false, error: `SMTP error: ${errorMsg}` }; } } // Parse Apprise URL and send notification async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise { const errors: string[] = []; for (const url of config.urls) { try { const result = await sendToAppriseUrl(url, payload); if (!result.success && result.error) { errors.push(result.error); } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); errors.push(`Failed to send: ${errorMsg}`); } } if (errors.length > 0) { return { success: false, error: errors.join('; ') }; } return { success: true }; } // Send to a single Apprise URL async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise { try { // Extract protocol from Apprise URL format (protocol://...) // Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs const protocolMatch = url.match(/^([a-z]+):\/\//i); if (!protocolMatch) { return { success: false, error: 'Invalid Apprise URL format - missing protocol' }; } const protocol = protocolMatch[1].toLowerCase(); // Handle different notification services switch (protocol) { case 'discord': case 'discords': return await sendDiscord(url, payload); case 'slack': case 'slacks': return await sendSlack(url, payload); case 'mmost': case 'mmosts': return await sendMattermost(url, payload); case 'tgram': return await sendTelegram(url, payload); case 'gotify': case 'gotifys': return await sendGotify(url, payload); case 'ntfy': case 'ntfys': return await sendNtfy(url, payload); case 'bark': case 'barks': return await sendBark(url, payload); case 'pushover': return await sendPushover(url, payload); case 'json': case 'jsons': return await sendGenericWebhook(url, payload); case 'workflows': return await sendWorkflows(url, payload); default: return { success: false, error: `Unsupported Apprise protocol: ${protocol}` }; } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` }; } } // Discord webhook async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise { // discord://webhook_id/webhook_token or discords://... const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/'); const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ embeds: [{ title: titleWithEnv, description: payload.message, color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff, ...(payload.environmentName && { footer: { text: `Environment: ${payload.environmentName}` } }) }] }) }); if (!response.ok) { const text = await response.text().catch(() => ''); return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Slack webhook async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise { // slack://token_a/token_b/token_c or webhook URL let url: string; if (appriseUrl.includes('hooks.slack.com')) { url = appriseUrl.replace(/^slacks?:\/\//, 'https://'); } else { const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/'); url = `https://hooks.slack.com/services/${parts.join('/')}`; } const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : ''; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: `*${payload.title}*${envTag}\n${payload.message}` }) }); if (!response.ok) { const text = await response.text().catch(() => ''); return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Mattermost webhook async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise { // mmost://[botname@]hostname[:port][/path]/token or mmosts://... const isSecure = appriseUrl.startsWith('mmosts'); const protocol = isSecure ? 'https' : 'http'; // Remove the scheme let urlPart = appriseUrl.replace(/^mmosts?:\/\//, ''); // Check for botname (username@hostname format) let username: string | undefined; const atIndex = urlPart.indexOf('@'); if (atIndex !== -1) { username = urlPart.substring(0, atIndex); urlPart = urlPart.substring(atIndex + 1); } // The token is the last segment, everything else is hostname[:port][/path] const lastSlashIndex = urlPart.lastIndexOf('/'); if (lastSlashIndex === -1) { return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' }; } const token = urlPart.substring(lastSlashIndex + 1); const hostAndPath = urlPart.substring(0, lastSlashIndex); // Build the webhook URL: {protocol}://{hostname}[:{port}][/{path}]/hooks/{token} const url = `${protocol}://${hostAndPath}/hooks/${token}`; const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : ''; const body: Record = { text: `*${payload.title}*${envTag}\n${payload.message}` }; if (username) { body.username = username; } try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { const text = await response.text().catch(() => ''); return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Telegram async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise { const parsed = parseTelegramUrl(appriseUrl); if (!parsed) { return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' }; } const { botToken, chatId, topicId } = parsed; const url = `https://api.telegram.org/bot${botToken}/sendMessage`; // Escape markdown special characters in title and message const escapedTitle = escapeTelegramMarkdown(payload.title); const escapedMessage = escapeTelegramMarkdown(payload.message); const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : ''; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text: `*${escapedTitle}*${envTag}\n${escapedMessage}`, ...(topicId ? { message_thread_id: topicId } : {}), parse_mode: 'Markdown', link_preview_options: { is_disabled: true } }) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})) as { description?: string }; const errorMsg = errorData.description || response.statusText; return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Gotify async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise { const parsed = buildGotifyUrl(appriseUrl); if (!parsed) { return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' }; } const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2; try { const response = await fetch(parsed.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: titleWithEnv, message: payload.message, priority: parsed.priority ?? defaultPriority }) }); if (!response.ok) { const text = await response.text().catch(() => ''); return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // ntfy async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise { // Supported formats: // ntfy://topic (public ntfy.sh) // ntfy://host/topic (custom server, no auth) // ntfy://user:pass@host/topic (custom server with basic auth) // ntfy://token@host/topic (custom server with bearer token) // ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token) // Query params: ?tags=ship,whale &title=Custom &priority=5 // ntfys:// variants for HTTPS const isSecure = appriseUrl.startsWith('ntfys'); const path = appriseUrl.replace(/^ntfys?:\/\//, ''); let url: string; let authHeader: string | null = null; // Extract query parameters (?auth=, ?tags=, ?title=, ?priority=) let queryAuth: string | null = null; let queryTags: string | null = null; let queryTitle: string | null = null; let queryPriority: string | null = null; let cleanPath = path; const qIndex = path.indexOf('?'); if (qIndex !== -1) { const params = new URLSearchParams(path.substring(qIndex + 1)); queryAuth = params.get('auth'); queryTags = params.get('tags'); queryTitle = params.get('title'); queryPriority = params.get('priority'); cleanPath = path.substring(0, qIndex); } // Check for user:pass@host/topic format (Basic auth) const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/); if (basicMatch) { const [, user, pass, hostAndTopic] = basicMatch; const basic = Buffer.from(`${user}:${pass}`).toString('base64'); authHeader = `Basic ${basic}`; url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`; } else if (cleanPath.includes('@') && cleanPath.includes('/')) { // token@host/topic -> Bearer token auth const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/); if (tokenMatch) { const [, token, hostAndTopic] = tokenMatch; authHeader = `Bearer ${token}`; url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`; } else { // Fallback to custom server without auth url = `${isSecure ? 'https' : 'http'}://${cleanPath}`; } } else if (cleanPath.includes('/')) { // Custom server without auth url = `${isSecure ? 'https' : 'http'}://${cleanPath}`; } else { // Default ntfy.sh url = `https://ntfy.sh/${cleanPath}`; } // Apply ?auth= as fallback if no explicit auth was set if (!authHeader && queryAuth) { const decoded = Buffer.from(queryAuth, 'base64').toString(); authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`; } const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; const defaultTags = payload.type || 'info'; const headers: Record = { 'Title': queryTitle || titleWithEnv, 'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'), 'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags }; if (authHeader) { headers['Authorization'] = authHeader; } try { const response = await fetch(url, { method: 'POST', headers, body: payload.message }); if (!response.ok) { const text = await response.text().catch(() => ''); return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Bark async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise { // Supported formats: // bark://device_key (official api.day.app server) // bark://host/device_key (custom server over HTTP) // barks://host/device_key (custom server over HTTPS) const isSecure = appriseUrl.startsWith('barks'); const path = appriseUrl.replace(/^barks?:\/\//, ''); let url: string; let deviceKey: string; if (!path.includes('/')) { if (!path) { return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' }; } url = 'https://api.day.app/push'; deviceKey = path; } else { const parts = path.split('/'); if (parts.length !== 2) { return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' }; } const [host, key] = parts; deviceKey = key; if (!host || !deviceKey) { return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' }; } url = `${isSecure ? 'https' : 'http'}://${host}/push`; } const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; const body: Record = { device_key: deviceKey, title: titleWithEnv, body: payload.message }; if (payload.type === 'error') { body.level = 'timeSensitive'; } try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { const text = await response.text().catch(() => ''); return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Pushover async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise { // pushover://user_key/api_token const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/); if (!match) { return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' }; } const [, userKey, apiToken] = match; const url = 'https://api.pushover.net/1/messages.json'; const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: apiToken, user: userKey, title: titleWithEnv, message: payload.message, priority: payload.type === 'error' ? 1 : 0 }) }); if (!response.ok) { const text = await response.text().catch(() => ''); return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Generic JSON webhook async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise { // json://hostname/path or jsons://hostname/path const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://'); try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: payload.title, message: payload.message, type: payload.type || 'info', environment: payload.environmentName || null, timestamp: new Date().toISOString() }) }); if (!response.ok) { const text = await response.text().catch(() => ''); return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Microsoft Power Automate Workflows, for e.g. Microsoft Teams async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise { const parsed = parseWorkflowsUrl(appriseUrl); if (!parsed) { return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' }; } const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature); const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'message', attachments: [ { contentType: 'application/vnd.microsoft.card.adaptive', content: { $schema: 'https://adaptivecards.io/schemas/adaptive-card.json', type: 'AdaptiveCard', version: '1.2', body: [ { type: 'TextBlock', style: 'heading', wrap: true, text: titleWithEnv }, { type: 'TextBlock', style: 'default', wrap: true, text: payload.message } ] } } ] }) }); if (!response.ok) { const text = await response.text().catch(() => ''); return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` }; } await drainResponse(response); return { success: true }; } catch (error) { return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Send notification to all enabled channels export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> { const settings = await getEnabledNotificationSettings(); const results: { name: string; success: boolean }[] = []; for (const setting of settings) { let result: NotificationResult = { success: false }; if (setting.type === 'smtp') { result = await sendSmtpNotification(setting.config as SmtpConfig, payload); } else if (setting.type === 'apprise') { result = await sendAppriseNotification(setting.config as AppriseConfig, payload); } results.push({ name: setting.name, success: result.success }); } return { success: results.every(r => r.success), results }; } // Test a specific notification setting export async function testNotification(setting: NotificationSettingData): Promise { const payload: NotificationPayload = { title: 'Dockhand Test Notification', message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.', type: 'info' }; if (setting.type === 'smtp') { return await sendSmtpNotification(setting.config as SmtpConfig, payload); } else if (setting.type === 'apprise') { return await sendAppriseNotification(setting.config as AppriseConfig, payload); } return { success: false, error: 'Unknown notification type' }; } // Map Docker action to notification event type function mapActionToEventType(action: string): NotificationEventType | null { const mapping: Record = { 'start': 'container_started', 'stop': 'container_stopped', 'restart': 'container_restarted', 'die': 'container_exited', 'kill': 'container_exited', 'oom': 'container_oom', 'health_status: unhealthy': 'container_unhealthy', 'health_status: healthy': 'container_healthy', 'pull': 'image_pulled' }; return mapping[action] || null; } // Scanner image patterns to exclude from notifications const SCANNER_IMAGE_PATTERNS = [ 'anchore/grype', 'aquasec/trivy', 'ghcr.io/anchore/grype', 'ghcr.io/aquasecurity/trivy' ]; function isScannerContainer(image: string | null | undefined): boolean { if (!image) return false; const lowerImage = image.toLowerCase(); return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase())); } // Send notification for an environment-specific event export async function sendEnvironmentNotification( environmentId: number, action: string, payload: Omit, image?: string | null ): Promise<{ success: boolean; sent: number }> { const eventType = mapActionToEventType(action); if (!eventType) { // Not a notifiable event type return { success: true, sent: 0 }; } // Get environment name const env = await getEnvironment(environmentId); if (!env) { return { success: false, sent: 0 }; } // Get enabled notification channels for this environment and event type const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType); if (envNotifications.length === 0) { return { success: true, sent: 0 }; } const enrichedPayload: NotificationPayload = { ...payload, environmentId, environmentName: env.name }; // Check if this is a scanner container const isScanner = isScannerContainer(image); let sent = 0; let allSuccess = true; // Skip all notifications for scanner containers (Trivy, Grype) if (isScanner) { return { success: true, sent: 0 }; } for (const notif of envNotifications) { try { let result: NotificationResult = { success: false }; if (notif.channelType === 'smtp') { result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload); } else if (notif.channelType === 'apprise') { result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload); } if (result.success) sent++; else allSuccess = false; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg); allSuccess = false; } } return { success: allSuccess, sent }; } // Send notification for a specific event type (not mapped from Docker action) // Used for auto-update, git sync, vulnerability, and system events export async function sendEventNotification( eventType: NotificationEventType, payload: NotificationPayload, environmentId?: number ): Promise<{ success: boolean; sent: number }> { // Get environment name if provided let enrichedPayload = { ...payload }; if (environmentId) { const env = await getEnvironment(environmentId); if (env) { enrichedPayload.environmentId = environmentId; enrichedPayload.environmentName = env.name; } } // Get enabled notification channels for this event type let channels: Array<{ channel_type: 'smtp' | 'apprise'; channel_name: string; config: SmtpConfig | AppriseConfig; }> = []; if (environmentId) { // Environment-specific: get channels subscribed to this env and event type const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType); channels = envNotifications .filter(n => n.channelType && n.channelName) .map(n => ({ channel_type: n.channelType!, channel_name: n.channelName!, config: n.config })); } else { // System-wide: get all globally enabled channels that subscribe to this event type const globalSettings = await getEnabledNotificationSettings(); channels = globalSettings .filter(s => s.eventTypes?.includes(eventType)) .map(s => ({ channel_type: s.type, channel_name: s.name, config: s.config })); } if (channels.length === 0) { return { success: true, sent: 0 }; } let sent = 0; let allSuccess = true; for (const channel of channels) { try { let result: NotificationResult = { success: false }; if (channel.channel_type === 'smtp') { result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload); } else if (channel.channel_type === 'apprise') { result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload); } if (result.success) sent++; else allSuccess = false; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg); allSuccess = false; } } return { success: allSuccess, sent }; }