const nodemailer = require('nodemailer'); const axios = require('axios'); const path = require('path'); const fs = require('fs'); require('dotenv').config(); class NotificationService { constructor() { this.applyConfigFromEnv(); } applyConfigFromEnv() { this.setConfig({ email_enabled: process.env.EMAIL_ENABLED === 'true', email_host: process.env.EMAIL_HOST, email_port: parseInt(process.env.EMAIL_PORT), email_user: process.env.EMAIL_USER, email_pass: process.env.EMAIL_PASS, email_from: process.env.EMAIL_FROM, email_to: process.env.EMAIL_TO, webhook_enabled: process.env.WEBHOOK_ENABLED === 'true', webhook_url: process.env.WEBHOOK_URL, webhook_template: process.env.WEBHOOK_TEMPLATE || '', }); } setConfig(config) { this.emailEnabled = !!config.email_enabled; this.webhookEnabled = !!config.webhook_enabled; this.config = { ...this.config, ...config }; // Recreate transporter when email config changes; skip if incomplete if ( this.emailEnabled && config.email_host && config.email_port && config.email_user && config.email_pass ) { this.transporter = nodemailer.createTransport({ host: config.email_host, port: parseInt(config.email_port), secure: false, auth: { user: config.email_user, pass: config.email_pass, }, connectionTimeout: 10000, socketTimeout: 10000, }); } else { this.transporter = null; } } async sendAlert(resource, incident, stats = null) { // Check if incident is acknowledged if (incident.acknowledged) { return; } // Check maintenance windows if (this.isInMaintenanceWindow(resource.id)) { return; } // Check quiet hours if (this.isQuietHours(resource)) { return; } const isDown = incident.type === 'started'; const isSlow = incident.type === 'slow'; const statusEmoji = isDown ? '🔴' : isSlow ? '🟡' : '🟢'; const statusText = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP'; const slowDetail = isSlow ? `\nResponse Time: ${incident.responseTime}ms (threshold: ${incident.threshold}ms)` : ''; const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}${slowDetail}`; const promises = []; // Use resource-specific email if provided, otherwise fall back to global config if (this.emailEnabled && (resource.email_to || this.config.email_to)) { const targetsCsv = resource.email_to || this.config.email_to; const targets = String(targetsCsv) .split(',') .map(t => t.trim()) .filter(t => t && /.+@.+\..+/.test(t)); if (targets.length > 0) { for (const email of targets) { promises.push(this.sendEmail(resource, message, incident.type, stats, email)); } } } else { if (!this.emailEnabled) { // Email globally disabled } else { // No email target configured } } if (this.webhookEnabled) { promises.push(this.sendWebhook(resource, message, incident.type)); } await Promise.allSettled(promises); // Save notification to in-app notification center const status = isDown ? 'down' : isSlow ? 'slow' : 'up'; this.saveNotification(resource, incident, status, message); } saveNotification(resource, incident, status, message) { try { const db = require('./database'); const title = status === 'down' ? `🔴 ${resource.name} is DOWN` : status === 'slow' ? `🟡 ${resource.name} is SLOW` : `🟢 ${resource.name} is UP`; const result = db.prepare(` INSERT INTO notifications (resource_id, incident_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, 0) `).run( resource.id, incident.id || null, status, title, message ); // Broadcast new notification to WebSocket clients in real-time if (global.broadcastNotification) { global.broadcastNotification({ id: result.lastInsertRowid, resource_id: resource.id, resource_name: resource.name, incident_id: incident.id || null, type: status, title, message, read: 0, created_at: new Date().toISOString() }); } } catch (error) { console.error('Failed to save notification:', error.message); } } async flushAlerts(pendingAlerts) { if (pendingAlerts.length === 0) return; if (pendingAlerts.length === 1) { const { resource, incident, stats } = pendingAlerts[0]; await this.sendAlert(resource, incident, stats); return; } // Multiple alerts in one check cycle — save individual in-app entries but send one grouped email/webhook for (const { resource, incident } of pendingAlerts) { const status = incident.type === 'started' ? 'down' : 'up'; const message = `${resource.name} is ${status.toUpperCase()}. URL: ${resource.url}`; this.saveNotification(resource, incident, status, message); } await this.sendGroupedEmail(pendingAlerts); if (this.webhookEnabled) { await this.sendGroupedWebhook(pendingAlerts); } } async sendGroupedEmail(alerts) { if (!this.transporter) return; const targetEmail = this.config.email_to; if (!targetEmail || !this.emailEnabled) return; try { const rows = alerts.map(({ resource, incident }) => { const status = incident.type === 'started' ? 'DOWN' : 'UP'; const emoji = incident.type === 'started' ? '🔴' : '🟢'; return `
Time: ${new Date().toLocaleString()}
Resource: ${resource.name}
Status: ${statusLabel}
URL: ${resource.url}
Check Type: ${resource.type || 'http'}
Time: ${new Date().toLocaleString()}
`; if (stats) { const uptimePct = Number(stats.uptime); const avgMs = Number(stats.avgResponseTime); htmlContent += `Uptime: ${Number.isFinite(uptimePct) ? (uptimePct * 100).toFixed(2) : 'N/A'}%
Avg Response Time: ${Number.isFinite(avgMs) ? avgMs.toFixed(0) : 'N/A'}ms
Last Check: ${stats.lastCheck || 'Never'}
`; if (stats.recentChecks && stats.recentChecks.length > 0) { const graph = this.generateAsciiGraph(stats.recentChecks); htmlContent += `Recent Response Times (last 12 checks):
${graph}
`;
}
htmlContent += `