mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
b7a8cca387
Add Bark as a supported Apprise notification protocol. Supported URL formats: - bark://bark_key uses the official Bark server at https://api.day.app/ - bark://host/bark_key uses a custom Bark server over HTTP - barks://host/bark_key uses a custom Bark server over HTTPS Bark notifications are sent with POST JSON payloads containing the device key, title, and body. The notification settings modal now lists Bark examples in the Apprise URL placeholder and support text.
823 lines
27 KiB
TypeScript
823 lines
27 KiB
TypeScript
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<void> {
|
|
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<NotificationResult> {
|
|
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
|
|
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
|
: '';
|
|
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
|
|
|
|
const html = `
|
|
<div style="font-family: sans-serif; padding: 20px;">
|
|
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
|
|
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
|
|
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
|
|
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
|
|
</div>
|
|
`;
|
|
|
|
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<NotificationResult> {
|
|
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<NotificationResult> {
|
|
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<NotificationResult> {
|
|
// 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<NotificationResult> {
|
|
// 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<NotificationResult> {
|
|
// 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<string, string> = {
|
|
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<NotificationResult> {
|
|
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<NotificationResult> {
|
|
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<NotificationResult> {
|
|
// 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<string, string> = {
|
|
'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<NotificationResult> {
|
|
// 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<string, string> = {
|
|
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<NotificationResult> {
|
|
// 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<NotificationResult> {
|
|
// 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<NotificationResult> {
|
|
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<NotificationResult> {
|
|
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<string, NotificationEventType> = {
|
|
'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<NotificationPayload, 'environmentId' | 'environmentName'>,
|
|
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 };
|
|
}
|