/** * Notification Manager - Multi-provider notification delivery * Supports Discord, Telegram, ntfy, and Email notifications */ const EventEmitter = require('events'); const fs = require('fs'); const path = require('path'); const nodemailer = require('nodemailer'); const { atomicWriteJSON } = require('../utils/atomic-write'); // Canonical event names are kebab-case ('container-down'). Emitters and the // settings UI historically send camelCase ('containerDown', 'deploymentSuccess') // and the alias map below folds every known spelling onto the canonical key. // DC-092: before this map, the events gate looked up the RAW event name, so // 'deploymentSuccess' (routes/apps/deploy.js, routes/recipes/deploy.js) and // 'auto-restart' (resource-monitor.js) were absent from DEFAULT events and // silently dropped, and UI camelCase toggles never reached the kebab keys the // gate reads — the toggles were cosmetic. // DC-094: recipe emitters used 'recipeRemoved' (camelCase) — alias onto the // canonical kebab key like every other spelling drift before it. const EVENT_ALIASES = { containerDown: 'container-down', containerUp: 'container-up', deploymentSuccess: 'deploy-success', deploymentFailed: 'deploy-failed', deploySuccess: 'deploy-success', deployFailed: 'deploy-failed', resourceAlert: 'alert', updateAvailable: 'update-available', backupComplete: 'backup-complete', backupFailed: 'backup-failed', autoRestart: 'auto-restart', recipeRemoved: 'recipe-removed', // DC-094: dependency-manager fires two spellings; one canonical toggle // gates both (stored configs with either key fold onto it at load). 'dependency-restart-complete': 'dependency-restart', 'dependency-restart-failed': 'dependency-restart', }; const DEFAULT_CONFIG = { enabled: true, providers: { discord: { enabled: false, webhookUrl: '' }, telegram: { enabled: false, botToken: '', chatId: '' }, ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' }, email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' } }, events: { 'container-down': true, 'container-up': false, 'alert': true, 'backup-complete': true, 'backup-failed': true, 'update-available': true, // DC-092: emitters (apps/recipes deploy routes) fire these; they were // missing from defaults entirely, so every deploy notification was // silently dropped before this fix. 'deploy-success': true, 'deploy-failed': true, 'auto-restart': true, // DC-094: seven more emitters were absent from DEFAULT events, so the // send() gate (config.events[canonical] !== true) silently dropped every // one of them: SSL expiry warnings, DNS propagation results, config // drift alerts, dependency restart results, recipe removals, and // workflow notify actions. All default ON — every one of these fires // only when something actually happened (and workflow notify actions // are explicitly authored by the operator, so an off-by-default gate // would just re-create this same silent-death bug). Recipe DEPLOY // notifications need no key: recipes/deploy.js emits the // deploymentSuccess/deploymentFailed aliases → deploy-success/failed. 'ssl-cert-expiry': true, 'dns-propagation': true, 'drift-detected': true, 'dependency-restart': true, 'recipe-removed': true, 'workflow': true } }; class NotificationManager extends EventEmitter { constructor(ctx) { super(); this.ctx = ctx; this.NOTIFICATIONS_FILE = ctx.NOTIFICATIONS_FILE; this.log = ctx.log || console; this.config = { ...DEFAULT_CONFIG }; this.lastSent = null; this.history = []; this.maxHistory = 100; this.healthDaemonInterval = null; this.healthState = new Map(); this._loadConfig(); } /** * Load config from file */ _loadConfig() { try { if (fs.existsSync(this.NOTIFICATIONS_FILE)) { const raw = fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'); const data = JSON.parse(raw); this._canonicalizeLegacyKeys(data); this.config = this._mergeConfig(DEFAULT_CONFIG, data); this._persistCanonicalForm(raw); } } catch (error) { this.log.error('notification', error, null, { note: 'Failed to load config' }); } } /** * DC-097: _canonicalizeLegacyKeys only fixed the file in memory — the * on-disk file kept its legacy spellings (email user/pass, camelCase * event keys, string `secure`) until the next explicit UI save, so any * pre-DC-092 file stayed stale forever on installs that never touch the * settings page. After the defaults merge, persist the canonical form * whenever it differs from what is on disk. Best-effort: the config is * already correct in memory, so a write failure (read-only mount, EACCES) * must never block startup — warn and continue. Idempotent: once written, * the re-serialized form matches the file byte-for-byte and no further * writes happen on subsequent loads. */ _persistCanonicalForm(rawFileContents) { try { const canonical = JSON.stringify(this.config, null, 2); if (canonical !== rawFileContents) { // DC-099: tmp+fsync+rename — a crash mid-write can no longer leave a // truncated/empty notifications.json (plain writeFileSync could). atomicWriteJSON(this.NOTIFICATIONS_FILE, this.config); this.log.info?.('notification', 'Notification config canonicalized on disk (legacy keys normalized)', {}); } } catch (writeError) { this.log.warn?.('notification', 'Failed to persist canonicalized notification config; continuing with in-memory config', { error: writeError?.message || String(writeError) }); } } /** * DC-092: configs saved by older clients may contain the legacy spellings * the old POST /config merged verbatim — email.user/email.pass instead of * username/password, and camelCase event keys instead of kebab-case. Fold * them onto the canonical keys BEFORE the defaults merge (after the merge * the canonical keys always exist from defaults, so the alias guards would * never fire) so a config file written before this fix keeps working: SMTP * auth applies and event toggles gate correctly. */ _canonicalizeLegacyKeys(data) { // Email credentials: user/pass → username/password (only when the // canonical key is absent in the raw data; canonical wins on conflict). const email = data?.providers?.email; if (email && typeof email === 'object') { if (email.user !== undefined && email.username === undefined) email.username = email.user; if (email.pass !== undefined && email.password === undefined) email.password = email.pass; delete email.user; delete email.pass; // secure must be a real boolean: legacy string values (e.g. "false" // from hand-edited JSON) are truthy under !! and would force TLS. if (email.secure !== undefined) email.secure = email.secure === true; } // Event keys: camelCase → kebab-case canonical. if (data?.events && typeof data.events === 'object') { for (const [k, v] of Object.entries(data.events)) { const canonicalKey = EVENT_ALIASES[k]; if (canonicalKey) { if (data.events[canonicalKey] === undefined) data.events[canonicalKey] = v; delete data.events[k]; } } } } /** * Merge loaded config with defaults */ _mergeConfig(defaults, loaded) { const result = { ...defaults }; for (const key of Object.keys(defaults)) { if (loaded && typeof defaults[key] === 'object' && !Array.isArray(defaults[key])) { result[key] = { ...defaults[key], ...loaded[key] }; } else if (loaded && loaded[key] !== undefined) { result[key] = loaded[key]; } } return result; } /** * Get current config (for API) */ getConfig() { return this.config; } /** * Save config to file */ async saveConfig() { try { const dir = path.dirname(this.NOTIFICATIONS_FILE); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } // DC-099: atomic tmp+fsync+rename — the UI save path gets the same // crash-safety as the load-path write-back (no torn notifications.json). atomicWriteJSON(this.NOTIFICATIONS_FILE, this.config); return true; } catch (error) { this.log.error('notification', error, null, { note: 'Failed to save config' }); throw error; } } /** * Get notification history */ getHistory() { return this.history.slice(); } /** * Clear notification history */ clearHistory() { this.history = []; } /** * Add entry to history */ _addToHistory(entry) { this.history.unshift({ ...entry, timestamp: new Date().toISOString() }); if (this.history.length > this.maxHistory) { this.history = this.history.slice(0, this.maxHistory); } this.lastSent = new Date().toISOString(); } /** * Send notification via all enabled providers */ async send(event, data, type = 'info') { // DC-094: nine in-repo call sites used a legacy 4-arg shape // send(event, title, message, type) against this 3-arg signature, so the // message string silently landed in the `type` slot (Discord embed color // fell back to info-blue) and providers received the TITLE as the body — // deploy-failure notifications lost the actual error text entirely. The // in-repo sites are fixed at source; this shim stays so any external // caller of the same legacy shape keeps working instead of silently // degrading again. Guarded on typeof data === 'string': the legacy shape // always passed a string title as arg 2, so a hypothetical // send(event, {...}, type, extra) call is left untouched rather than // mangled by the rewrite. if (arguments.length >= 4 && typeof data === 'string') { const legacyTitle = data; const legacyMessage = type; const legacyType = arguments[3]; data = { title: legacyTitle, text: legacyMessage }; type = legacyType || 'info'; } if (!this.config.enabled) { return { success: false, error: 'Notifications disabled' }; } // Fold legacy/camelCase spellings onto canonical kebab-case keys (DC-092). const canonical = EVENT_ALIASES[event] || event; // Check if event is enabled. 'test' bypasses the gate: it is the settings // UI "Send Test" flow and is not an operator-togglable event (there is no // 'test' key in events; gating on it made the Test button a no-op). const gated = canonical !== 'test'; if (gated && this.config.events && this.config.events[canonical] !== true) { return { success: false, error: `Event ${canonical} not enabled` }; } // Provider-facing title: an explicit data.title (legacy 4-arg callers // passed a specific one, e.g. "Recipe Deployed") beats the generic // per-event title. const title = (data && typeof data === 'object' && typeof data.title === 'string' && data.title) || this._formatTitle(canonical); const results = []; const providers = this.config.providers; // Discord if (providers.discord?.enabled && providers.discord?.webhookUrl) { try { const result = await this.sendDiscord(this._formatText(data, canonical), this._formatEmbed(data, canonical, type)); results.push({ provider: 'discord', ...result }); } catch (error) { results.push({ provider: 'discord', success: false, error: error.message }); } } // Telegram if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) { try { const result = await this.sendTelegram(this._formatText(data, canonical)); results.push({ provider: 'telegram', ...result }); } catch (error) { results.push({ provider: 'telegram', success: false, error: error.message }); } } // ntfy if (providers.ntfy?.enabled && providers.ntfy?.topic) { try { const result = await this.sendNtfy(this._formatText(data, canonical), title); results.push({ provider: 'ntfy', ...result }); } catch (error) { results.push({ provider: 'ntfy', success: false, error: error.message }); } } // Email if (providers.email?.enabled && providers.email?.host && providers.email?.to) { try { const result = await this.sendEmail( title, this._formatText(data, canonical) ); results.push({ provider: 'email', ...result }); } catch (error) { results.push({ provider: 'email', success: false, error: error.message }); } } const allSucceeded = results.every(r => r.success); this._addToHistory({ title, type, event: canonical, results }); return { success: allSucceeded, results }; } /** * Send Discord webhook notification */ async sendDiscord(text, embed) { const { webhookUrl } = this.config.providers.discord; if (!webhookUrl) { throw new Error('Discord webhook not configured'); } const payload = { content: text, embeds: embed ? [embed] : [] }; const response = await this.ctx.fetchT(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`Discord API error: ${response.status}`); } return { success: true }; } /** * Send Telegram message */ async sendTelegram(text) { const { botToken, chatId } = this.config.providers.telegram; if (!botToken || !chatId) { throw new Error('Telegram not configured'); } const url = `https://api.telegram.org/bot${botToken}/sendMessage`; const payload = { chat_id: chatId, text, parse_mode: 'Markdown' }; const response = await this.ctx.fetchT(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (!data.ok) { throw new Error(`Telegram error: ${data.description}`); } return { success: true }; } /** * Send ntfy notification */ async sendNtfy(text, title) { const { serverUrl, topic } = this.config.providers.ntfy; if (!topic) { throw new Error('ntfy topic not configured'); } const url = `${serverUrl.replace(/\/$/, '')}/${topic}`; const response = await this.ctx.fetchT(url, { method: 'POST', headers: { 'Content-Type': 'text/plain', 'Title': title || 'DashCaddy', 'Priority': '3' }, body: text }); if (!response.ok) { throw new Error(`ntfy error: ${response.status}`); } return { success: true }; } /** * Send email notification */ async sendEmail(subject, body) { const { host, port, to, from, username, password, secure } = this.config.providers.email; if (!host || !to) { throw new Error('Email not configured'); } // Create transporter const transporter = nodemailer.createTransport({ host, port: parseInt(port) || 587, secure: secure === true, auth: username ? { user: username, pass: password } : undefined }); // Send mail await transporter.sendMail({ from: from || username, to, subject, text: body, html: `
${body}`
});
return { success: true };
}
/**
* Send resource alert
*/
async sendAlert(alert) {
const text = this._formatAlertText(alert);
const embed = {
title: `⚠️ Resource Alert: ${alert.containerName}`,
color: this._getAlertColor(alert.alerts),
fields: alert.alerts.map(a => ({
name: a.type.toUpperCase(),
value: a.message,
inline: true
})),
footer: {
text: 'DashCaddy Resource Monitor'
},
timestamp: alert.timestamp
};
return this.send('alert', { ...alert, text, embed }, 'warning');
}
/**
* Send backup complete notification
*/
async sendBackupComplete(backup) {
const event = backup.status === 'success' ? 'backup-complete' : 'backup-failed';
const type = backup.status === 'success' ? 'success' : 'error';
const text = backup.status === 'success'
? `✅ Backup "${backup.name}" completed successfully`
: `❌ Backup "${backup.name}" failed: ${backup.error}`;
return this.send(event, { ...backup, text }, type);
}
/**
* Send service event notification (container up/down, deploy success/fail)
*/
async sendServiceEvent(event, service) {
const eventMap = {
'container-up': { type: 'success', text: `✅ Container "${service.containerName || service.name}" is now UP` },
'container-down': { type: 'error', text: `🔴 Container "${service.containerName || service.name}" is DOWN` },
'deploy-success': { type: 'success', text: `✅ "${service.name}" deployed successfully` },
'deploy-failed': { type: 'error', text: `❌ "${service.name}" deployment failed` },
'auto-restart': { type: 'warning', text: `🔄 Container "${service.containerName || service.name}" auto-restarted` }
};
const info = eventMap[event] || { type: 'info', text: `Service event: ${event}` };
return this.send(event, { ...service, text: info.text }, info.type);
}
// ===== Helper Methods =====
_formatTitle(event) {
const titles = {
'container-down': 'Container Down',
'container-up': 'Container Recovered',
'alert': 'Resource Alert',
'backup-complete': 'Backup Complete',
'backup-failed': 'Backup Failed',
'update-available': 'Update Available',
'test': 'Test Notification',
'auto-restart': 'Auto-Restart',
'deploy-success': 'Deployment Success',
'deploy-failed': 'Deployment Failed',
// DC-094: newly gated events get real provider titles too.
'ssl-cert-expiry': 'SSL Certificate Expiry',
'dns-propagation': 'DNS Propagation',
'drift-detected': 'Configuration Drift',
'dependency-restart': 'Dependency Restart',
'recipe-removed': 'Recipe Removed',
'workflow': 'Workflow Notification'
};
return titles[event] || 'DashCaddy Notification';
}
_formatText(data, event) {
if (typeof data === 'string') return data;
return data.text || data.message || this._formatTitle(event);
}
_formatEmbed(data, event, type) {
if (typeof data === 'string') return null;
if (data.embed) return data.embed;
return {
title: (typeof data.title === 'string' && data.title) || this._formatTitle(event),
description: data.text || data.message || '',
color: this._getTypeColor(type),
timestamp: new Date().toISOString()
};
}
_formatAlertText(alert) {
const lines = [
`**${alert.containerName}**`,
'',
...alert.alerts.map(a => `• ${a.message}`)
];
return lines.join('\n');
}
_getAlertColor(alerts) {
if (alerts.some(a => a.severity === 'critical')) return 15158332; // Red
if (alerts.some(a => a.severity === 'warning')) return 16776960; // Yellow
return 3447003; // Blue
}
_getTypeColor(type) {
const colors = {
success: 3066993, // Green
error: 15158332, // Red
warning: 16776960, // Yellow
info: 3447003 // Blue
};
return colors[type] || colors.info;
}
// ===== Health Check Daemon =====
startHealthDaemon() {
if (this.healthDaemonInterval) return;
const interval = (this.config.healthCheck?.intervalMinutes || 5) * 60 * 1000;
this.healthDaemonInterval = setInterval(() => {
this.checkHealth().catch(err => {
this.log.error('notification', err, null, { note: 'Health check failed' });
});
}, interval);
this.log.info('notification', 'Health daemon started', { intervalMinutes: this.config.healthCheck?.intervalMinutes });
}
stopHealthDaemon() {
if (this.healthDaemonInterval) {
clearInterval(this.healthDaemonInterval);
this.healthDaemonInterval = null;
this.log.info('notification', 'Health daemon stopped');
}
}
async checkHealth() {
if (!this.config.healthCheck?.enabled || !this.ctx.docker) {
return { checked: false };
}
try {
const containers = await this.ctx.docker.listContainers({ all: true });
const previousState = new Map(this.healthState);
for (const container of containers) {
const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
const wasDown = previousState.get(container.Id) === false;
const isDown = container.State !== 'running';
this.healthState.set(container.Id, isDown);
if (wasDown && !isDown) {
// Container recovered
await this.sendServiceEvent('container-up', {
containerId: container.Id,
containerName: name,
state: container.State
});
} else if (!wasDown && isDown) {
// Container went down
await this.sendServiceEvent('container-down', {
containerId: container.Id,
containerName: name,
state: container.State
});
}
}
// Update last check time
this.config.healthCheck = this.config.healthCheck || {};
this.config.healthCheck.lastCheck = new Date().toISOString();
await this.saveConfig();
return {
checked: true,
containersMonitored: containers.length,
lastCheck: this.config.healthCheck.lastCheck
};
} catch (error) {
this.log.error('notification', error, null, { note: 'Health check error' });
throw error;
}
}
getHealthState() {
return new Map(this.healthState);
}
}
module.exports = NotificationManager;