Skip to content

Commit dd98d77

Browse files
rcourtmanclaude
andcommitted
feat: implement email notifications for alerts
- Add nodemailer dependency for SMTP email sending - Implement email notification channel in AlertManager - Add rich HTML email templates with alert details - Create email configuration UI in settings page - Add test email functionality with validation - Support for multiple recipients and Gmail app passwords - Addresses feature request in issue #111 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f7303b5 commit dd98d77

File tree

5 files changed

+447
-12
lines changed

5 files changed

+447
-12
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"cors": "^2.8.5",
3030
"dotenv": "^16.5.0",
3131
"express": "^4.21.2",
32+
"nodemailer": "^6.9.18",
3233
"p-limit": "^6.2.0",
3334
"semver": "^7.7.2",
3435
"socket.io": "^4.7.2",

server/alertManager.js

Lines changed: 171 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const EventEmitter = require('events');
22
const fs = require('fs').promises;
33
const path = require('path');
4+
const nodemailer = require('nodemailer');
45
const customThresholdManager = require('./customThresholds');
56

67
class AlertManager extends EventEmitter {
@@ -36,6 +37,10 @@ class AlertManager extends EventEmitter {
3637
// Initialize custom threshold manager
3738
this.initializeCustomThresholds();
3839

40+
// Initialize email transporter
41+
this.emailTransporter = null;
42+
this.initializeEmailTransporter();
43+
3944
// Cleanup timer for resolved alerts
4045
this.cleanupInterval = setInterval(() => {
4146
this.cleanupResolvedAlerts();
@@ -544,18 +549,27 @@ class AlertManager extends EventEmitter {
544549
});
545550
}
546551

547-
sendToChannel(channel, alert) {
548-
// This would implement actual notification sending
549-
// For now, just log it
550-
console.log(`[NOTIFICATION] Sending to ${channel.name}:`, {
551-
channel: channel.type,
552-
alert: alert.rule.name,
553-
severity: alert.rule.severity,
554-
guest: alert.guest.name
555-
});
556-
557-
// Emit event for external handlers
558-
this.emit('notification', { channel, alert });
552+
async sendToChannel(channel, alert) {
553+
try {
554+
console.log(`[NOTIFICATION] Sending to ${channel.name}:`, {
555+
channel: channel.type,
556+
alert: alert.rule.name,
557+
severity: alert.rule.severity,
558+
guest: alert.guest.name
559+
});
560+
561+
if (channel.type === 'email') {
562+
await this.sendEmailNotification(channel, alert);
563+
} else if (channel.type === 'webhook') {
564+
await this.sendWebhookNotification(channel, alert);
565+
}
566+
567+
// Emit event for external handlers
568+
this.emit('notification', { channel, alert });
569+
} catch (error) {
570+
console.error(`[NOTIFICATION ERROR] Failed to send to ${channel.name}:`, error);
571+
this.emit('notificationError', { channel, alert, error });
572+
}
559573
}
560574

561575
updateMetrics() {
@@ -991,6 +1005,146 @@ class AlertManager extends EventEmitter {
9911005
}
9921006
}
9931007

1008+
/**
1009+
* Initialize email transporter for sending notifications
1010+
*/
1011+
initializeEmailTransporter() {
1012+
if (process.env.SMTP_HOST) {
1013+
try {
1014+
this.emailTransporter = nodemailer.createTransporter({
1015+
host: process.env.SMTP_HOST,
1016+
port: parseInt(process.env.SMTP_PORT) || 587,
1017+
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
1018+
auth: {
1019+
user: process.env.SMTP_USER,
1020+
pass: process.env.SMTP_PASS
1021+
}
1022+
});
1023+
console.log('[AlertManager] Email transporter initialized');
1024+
} catch (error) {
1025+
console.error('[AlertManager] Failed to initialize email transporter:', error);
1026+
}
1027+
} else {
1028+
console.log('[AlertManager] SMTP not configured, email notifications disabled');
1029+
}
1030+
}
1031+
1032+
/**
1033+
* Send email notification
1034+
*/
1035+
async sendEmailNotification(channel, alert) {
1036+
if (!this.emailTransporter) {
1037+
throw new Error('Email transporter not configured');
1038+
}
1039+
1040+
const recipients = channel.config.to;
1041+
if (!recipients || recipients.length === 0) {
1042+
throw new Error('No email recipients configured');
1043+
}
1044+
1045+
const severityEmoji = {
1046+
'info': '💙',
1047+
'warning': '⚠️',
1048+
'critical': '🚨'
1049+
};
1050+
1051+
const subject = `${severityEmoji[alert.rule.severity] || '📢'} Pulse Alert: ${alert.rule.name}`;
1052+
1053+
const html = `
1054+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
1055+
<div style="background: ${alert.rule.severity === 'critical' ? '#dc2626' : alert.rule.severity === 'warning' ? '#ea580c' : '#2563eb'}; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
1056+
<h1 style="margin: 0; font-size: 24px;">${severityEmoji[alert.rule.severity] || '📢'} ${alert.rule.name}</h1>
1057+
<p style="margin: 5px 0 0 0; opacity: 0.9; font-size: 16px;">Severity: ${alert.rule.severity.toUpperCase()}</p>
1058+
</div>
1059+
1060+
<div style="background: #f9fafb; padding: 20px; border-left: 4px solid ${alert.rule.severity === 'critical' ? '#dc2626' : alert.rule.severity === 'warning' ? '#ea580c' : '#2563eb'};">
1061+
<h2 style="margin: 0 0 15px 0; color: #374151;">Alert Details</h2>
1062+
1063+
<table style="width: 100%; border-collapse: collapse;">
1064+
<tr>
1065+
<td style="padding: 8px 0; font-weight: bold; color: #374151; width: 120px;">VM/LXC:</td>
1066+
<td style="padding: 8px 0; color: #6b7280;">${alert.guest.name} (${alert.guest.type} ${alert.guest.id})</td>
1067+
</tr>
1068+
<tr>
1069+
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Node:</td>
1070+
<td style="padding: 8px 0; color: #6b7280;">${alert.guest.node}</td>
1071+
</tr>
1072+
<tr>
1073+
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Metric:</td>
1074+
<td style="padding: 8px 0; color: #6b7280;">${alert.rule.metric.toUpperCase()}</td>
1075+
</tr>
1076+
<tr>
1077+
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Current Value:</td>
1078+
<td style="padding: 8px 0; color: #6b7280;">${alert.value}%</td>
1079+
</tr>
1080+
<tr>
1081+
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Threshold:</td>
1082+
<td style="padding: 8px 0; color: #6b7280;">${alert.threshold}%</td>
1083+
</tr>
1084+
<tr>
1085+
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Status:</td>
1086+
<td style="padding: 8px 0; color: #6b7280;">${alert.guest.status}</td>
1087+
</tr>
1088+
<tr>
1089+
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Time:</td>
1090+
<td style="padding: 8px 0; color: #6b7280;">${new Date(alert.timestamp).toLocaleString()}</td>
1091+
</tr>
1092+
</table>
1093+
</div>
1094+
1095+
<div style="background: white; padding: 20px; border-radius: 0 0 8px 8px; border-top: 1px solid #e5e7eb;">
1096+
<p style="margin: 0; color: #6b7280; font-size: 14px;">
1097+
<strong>Description:</strong> ${alert.rule.description}
1098+
</p>
1099+
<p style="margin: 15px 0 0 0; color: #9ca3af; font-size: 12px;">
1100+
This alert was generated by Pulse monitoring system.
1101+
Please check your Proxmox dashboard for more details.
1102+
</p>
1103+
</div>
1104+
</div>
1105+
`;
1106+
1107+
const text = `
1108+
PULSE ALERT: ${alert.rule.name}
1109+
1110+
Severity: ${alert.rule.severity.toUpperCase()}
1111+
VM/LXC: ${alert.guest.name} (${alert.guest.type} ${alert.guest.id})
1112+
Node: ${alert.guest.node}
1113+
Metric: ${alert.rule.metric.toUpperCase()}
1114+
Current Value: ${alert.value}%
1115+
Threshold: ${alert.threshold}%
1116+
Status: ${alert.guest.status}
1117+
Time: ${new Date(alert.timestamp).toLocaleString()}
1118+
1119+
Description: ${alert.rule.description}
1120+
1121+
This alert was generated by Pulse monitoring system.
1122+
`;
1123+
1124+
const mailOptions = {
1125+
from: channel.config.from,
1126+
to: recipients.join(', '),
1127+
subject: subject,
1128+
text: text,
1129+
html: html
1130+
};
1131+
1132+
await this.emailTransporter.sendMail(mailOptions);
1133+
console.log(`[EMAIL] Alert sent to: ${recipients.join(', ')}`);
1134+
}
1135+
1136+
/**
1137+
* Send webhook notification (placeholder for future implementation)
1138+
*/
1139+
async sendWebhookNotification(channel, alert) {
1140+
if (!channel.config.url) {
1141+
throw new Error('Webhook URL not configured');
1142+
}
1143+
1144+
// Placeholder for webhook implementation
1145+
console.log(`[WEBHOOK] Would send to: ${channel.config.url}`);
1146+
}
1147+
9941148
destroy() {
9951149
if (this.cleanupInterval) {
9961150
clearInterval(this.cleanupInterval);
@@ -1003,6 +1157,11 @@ class AlertManager extends EventEmitter {
10031157
this.alertRules.clear();
10041158
this.acknowledgedAlerts.clear();
10051159
this.suppressedAlerts.clear();
1160+
1161+
// Close email transporter
1162+
if (this.emailTransporter) {
1163+
this.emailTransporter.close();
1164+
}
10061165
}
10071166
}
10081167

server/index.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,105 @@ app.get('/api/diagnostics', async (req, res) => {
762762
}
763763
});
764764

765+
// Test email endpoint
766+
app.post('/api/test-email', async (req, res) => {
767+
try {
768+
const { host, port, user, pass, from, to, secure } = req.body;
769+
770+
if (!host || !port || !user || !pass || !from || !to) {
771+
return res.status(400).json({
772+
success: false,
773+
error: 'All email fields are required for testing'
774+
});
775+
}
776+
777+
// Create a temporary transporter for testing
778+
const nodemailer = require('nodemailer');
779+
const testTransporter = nodemailer.createTransporter({
780+
host: host,
781+
port: parseInt(port),
782+
secure: secure === true,
783+
auth: {
784+
user: user,
785+
pass: pass
786+
}
787+
});
788+
789+
// Send test email
790+
const testMailOptions = {
791+
from: from,
792+
to: to,
793+
subject: '🧪 Pulse Email Test - Configuration Successful',
794+
text: `
795+
This is a test email from your Pulse monitoring system.
796+
797+
If you received this email, your SMTP configuration is working correctly!
798+
799+
Configuration used:
800+
- SMTP Host: ${host}
801+
- SMTP Port: ${port}
802+
- Secure: ${secure ? 'Yes' : 'No'}
803+
- From: ${from}
804+
- To: ${to}
805+
806+
You will now receive alert notifications when VMs/LXCs exceed their configured thresholds.
807+
808+
Best regards,
809+
Pulse Monitoring System
810+
`,
811+
html: `
812+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
813+
<div style="background: #059669; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
814+
<h1 style="margin: 0; font-size: 24px;">🧪 Pulse Email Test</h1>
815+
<p style="margin: 5px 0 0 0; opacity: 0.9;">Configuration Successful!</p>
816+
</div>
817+
818+
<div style="background: #f0fdf4; padding: 20px; border-left: 4px solid #059669;">
819+
<p style="margin: 0 0 15px 0; color: #065f46;">
820+
<strong>Congratulations!</strong> If you received this email, your SMTP configuration is working correctly.
821+
</p>
822+
823+
<h3 style="color: #065f46; margin: 15px 0 10px 0;">Configuration Details:</h3>
824+
<ul style="color: #047857; margin: 0; padding-left: 20px;">
825+
<li><strong>SMTP Host:</strong> ${host}</li>
826+
<li><strong>SMTP Port:</strong> ${port}</li>
827+
<li><strong>Secure:</strong> ${secure ? 'Yes (SSL/TLS)' : 'No (STARTTLS)'}</li>
828+
<li><strong>From Address:</strong> ${from}</li>
829+
<li><strong>To Address:</strong> ${to}</li>
830+
</ul>
831+
832+
<p style="margin: 15px 0 0 0; color: #065f46;">
833+
You will now receive alert notifications when VMs/LXCs exceed their configured thresholds.
834+
</p>
835+
</div>
836+
837+
<div style="background: white; padding: 20px; border-radius: 0 0 8px 8px; border-top: 1px solid #d1fae5;">
838+
<p style="margin: 0; color: #6b7280; font-size: 12px; text-align: center;">
839+
This test email was sent by your Pulse monitoring system.
840+
</p>
841+
</div>
842+
</div>
843+
`
844+
};
845+
846+
await testTransporter.sendMail(testMailOptions);
847+
testTransporter.close();
848+
849+
console.log(`[EMAIL TEST] Test email sent successfully to: ${to}`);
850+
res.json({
851+
success: true,
852+
message: 'Test email sent successfully!'
853+
});
854+
855+
} catch (error) {
856+
console.error('[EMAIL TEST] Failed to send test email:', error);
857+
res.status(400).json({
858+
success: false,
859+
error: error.message || 'Failed to send test email'
860+
});
861+
}
862+
});
863+
765864
// Global error handler for unhandled API errors
766865
app.use((err, req, res, next) => {
767866
console.error('Unhandled API error:', err);

0 commit comments

Comments
 (0)