Nya notifikations alternativ

This commit is contained in:
2026-06-16 21:37:11 +02:00
parent 8491fe1386
commit 125fddfb1b
8 changed files with 395 additions and 78 deletions
+24
View File
@@ -1,4 +1,28 @@
[
{
"id": "1780770465729-72o2",
"timestamp": "2026-06-06T18:27:45.730Z",
"provider": "cpanel",
"operation": "GET /execute/DomainInfo/list_domains",
"method": "GET",
"url": "https://cpsrv32.misshosting.com/execute/DomainInfo/list_domains",
"status": 200,
"latency": 18,
"ok": true,
"error": null
},
{
"id": "1780770465709-j66b",
"timestamp": "2026-06-06T18:27:45.709Z",
"provider": "cpanel",
"operation": "GET /execute/DNS/list_zones",
"method": "GET",
"url": "https://cpsrv32.misshosting.com/execute/DNS/list_zones",
"status": 200,
"latency": 82,
"ok": true,
"error": null
},
{
"id": "1780397326074-s7tw",
"timestamp": "2026-06-02T10:48:46.074Z",
+10
View File
@@ -14,6 +14,7 @@
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"node-schedule": "^2.1.1",
"nodemailer": "^6.9.14",
"xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1"
},
@@ -1008,6 +1009,15 @@
"node": ">=6"
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"bcryptjs": "^2.4.3",
"nodemailer": "^6.9.14",
"node-schedule": "^2.1.1",
"jsonwebtoken": "^9.0.2",
"xml2js": "^0.6.2",
+130 -37
View File
@@ -1,69 +1,162 @@
const settings = require('./settings');
const db = require('./db');
const settings = require('./settings');
const db = require('./db');
const nodemailer = require('nodemailer');
/**
* Send a Gotify notification if enabled and configured.
* Errors are logged but never thrown — notifications are best-effort.
*/
async function notify(title, message) {
// ─── Channel senders ──────────────────────────────────────────────────────────
async function sendGotify(title, message) {
const { gotify } = settings.get();
if (!gotify.enabled || !gotify.url || !gotify.token) return;
const base = gotify.url.replace(/\/$/, '');
try {
const res = await fetch(`${base}/message?token=${encodeURIComponent(gotify.token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
message,
priority: gotify.priority ?? 5,
}),
body: JSON.stringify({ title, message, priority: gotify.priority ?? 5 }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error(`[notify] Gotify error ${res.status}: ${text}`);
}
if (!res.ok) console.error(`[notify] Gotify error ${res.status}: ${await res.text().catch(() => '')}`);
} catch (err) {
console.error('[notify] Failed to send Gotify notification:', err.message);
console.error('[notify] Gotify failed:', err.message);
}
}
// ─── Convenience helpers ─────────────────────────────────────────────────────
async function sendNtfy(title, message) {
const { ntfy } = settings.get();
if (!ntfy.enabled || !ntfy.url || !ntfy.topic) return;
const base = ntfy.url.replace(/\/$/, '');
const headers = {
'Content-Type': 'text/plain',
'Title': title,
'Priority': String(ntfy.priority ?? 3),
};
if (ntfy.token) headers['Authorization'] = `Bearer ${ntfy.token}`;
try {
const res = await fetch(`${base}/${encodeURIComponent(ntfy.topic)}`, {
method: 'POST',
headers,
body: message,
});
if (!res.ok) console.error(`[notify] ntfy error ${res.status}: ${await res.text().catch(() => '')}`);
} catch (err) {
console.error('[notify] ntfy failed:', err.message);
}
}
async function sendSmtp(title, message) {
const { smtp } = settings.get();
if (!smtp.enabled || !smtp.host || !smtp.to || !smtp.from) return;
try {
const transporter = nodemailer.createTransport({
host: smtp.host,
port: Number(smtp.port) || 587,
secure: smtp.secure === true || smtp.secure === 'true',
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
});
await transporter.sendMail({
from: smtp.from,
to: smtp.to,
subject: title,
text: message,
});
} catch (err) {
console.error('[notify] SMTP failed:', err.message);
}
}
async function sendWebhook(title, message) {
const { webhook } = settings.get();
if (!webhook.enabled || !webhook.url) return;
const body = JSON.stringify({ content: `**${title}**\n${message}` });
const headers = { 'Content-Type': 'application/json' };
if (webhook.secret) headers['X-Webhook-Secret'] = webhook.secret;
try {
const res = await fetch(webhook.url, { method: 'POST', headers, body });
if (!res.ok) console.error(`[notify] Webhook error ${res.status}: ${await res.text().catch(() => '')}`);
} catch (err) {
console.error('[notify] Webhook failed:', err.message);
}
}
// ─── Broadcast to all enabled channels ───────────────────────────────────────
async function notify(title, message) {
await Promise.all([
sendGotify(title, message),
sendNtfy(title, message),
sendSmtp(title, message),
sendWebhook(title, message),
]);
}
// ─── Exported senders for test routes ────────────────────────────────────────
async function testGotify(cfg) {
const base = cfg.url.replace(/\/$/, '');
const res = await fetch(`${base}/message?token=${encodeURIComponent(cfg.token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '🦥 Sloth Manager — Test', message: 'Gotify notifications are working correctly.', priority: cfg.priority ?? 5 }),
});
if (!res.ok) throw new Error(`Gotify returned ${res.status}: ${await res.text().catch(() => '')}`);
}
async function testNtfy(cfg) {
const base = cfg.url.replace(/\/$/, '');
const headers = { 'Content-Type': 'text/plain', Title: '🦥 Sloth Manager — Test', Priority: String(cfg.priority ?? 3) };
if (cfg.token) headers['Authorization'] = `Bearer ${cfg.token}`;
const res = await fetch(`${base}/${encodeURIComponent(cfg.topic)}`, { method: 'POST', headers, body: 'ntfy notifications are working correctly.' });
if (!res.ok) throw new Error(`ntfy returned ${res.status}: ${await res.text().catch(() => '')}`);
}
async function testSmtp(cfg) {
const transporter = nodemailer.createTransport({
host: cfg.host,
port: Number(cfg.port) || 587,
secure: cfg.secure === true || cfg.secure === 'true',
auth: cfg.username ? { user: cfg.username, pass: cfg.password } : undefined,
});
await transporter.verify();
await transporter.sendMail({
from: cfg.from,
to: cfg.to,
subject: '🦥 Sloth Manager — Test',
text: 'SMTP notifications are working correctly.',
});
}
async function testWebhook(cfg) {
const headers = { 'Content-Type': 'application/json' };
if (cfg.secret) headers['X-Webhook-Secret'] = cfg.secret;
const res = await fetch(cfg.url, {
method: 'POST',
headers,
body: JSON.stringify({ content: '**🦥 Sloth Manager — Test**\nWebhook notifications are working correctly.' }),
});
if (!res.ok) throw new Error(`Webhook returned ${res.status}: ${await res.text().catch(() => '')}`);
}
// ─── Event helpers ────────────────────────────────────────────────────────────
function eventEnabled(key) {
const { notifications } = settings.get();
return notifications?.[key] !== false;
return settings.get().notifications?.[key] !== false;
}
function recordAdded(provider, zoneId, record) {
if (!eventEnabled('dns_add')) return;
const zoneName = db.getZoneName(provider, zoneId);
return notify(
`DNS Record Added`,
`[${provider}] ${zoneName}\n+ ${record.type} ${record.name}${record.content}`,
);
return notify(`DNS Record Added`, `[${provider}] ${zoneName}\n+ ${record.type} ${record.name}${record.content}`);
}
function recordUpdated(provider, zoneId, oldRecord, newRecord) {
if (!eventEnabled('dns_update')) return;
const zoneName = db.getZoneName(provider, zoneId);
return notify(
`DNS Record Updated`,
`[${provider}] ${zoneName}\n${oldRecord.type} ${oldRecord.name}\n ${oldRecord.content}${newRecord.content}`,
);
return notify(`DNS Record Updated`, `[${provider}] ${zoneName}\n${oldRecord.type} ${oldRecord.name}\n ${oldRecord.content}${newRecord.content}`);
}
function recordDeleted(provider, zoneId, record) {
if (!eventEnabled('dns_delete')) return;
const zoneName = db.getZoneName(provider, zoneId);
return notify(
`DNS Record Deleted`,
`[${provider}] ${zoneName}\n ${record.type} ${record.name} ${record.content}`,
);
return notify(`DNS Record Deleted`, `[${provider}] ${zoneName}\n ${record.type} ${record.name} ${record.content}`);
}
module.exports = { notify, recordAdded, recordUpdated, recordDeleted };
module.exports = { notify, testGotify, testNtfy, testSmtp, testWebhook, recordAdded, recordUpdated, recordDeleted };
+43 -29
View File
@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const settings = require('../settings');
const { notify } = require('../notify');
const { testGotify, testNtfy, testSmtp, testWebhook } = require('../notify');
// GET /api/settings
router.get('/', (req, res) => {
@@ -12,8 +12,7 @@ router.get('/', (req, res) => {
router.put('/', (req, res) => {
try {
const updated = settings.update(req.body);
// Reschedule secret check if the notification time changed
if (req.body.notifications?.secret_check_time) {
if (req.body.notifications?.secret_check_time || req.body.notifications?.timezone) {
try { require('../index').scheduleSecretCheck(); } catch { /* safe to ignore */ }
}
res.json(updated);
@@ -22,36 +21,51 @@ router.put('/', (req, res) => {
}
});
// POST /api/settings/test-notification
router.post('/test-notification', async (req, res) => {
// Use the payload from the request body so the user can test
// before saving (the frontend sends the current form values)
// POST /api/settings/test-gotify
router.post('/test-gotify', async (req, res) => {
const { url, token, priority } = req.body;
if (!url || !token) {
return res.status(400).json({ error: 'URL and token are required' });
}
const base = url.replace(/\/$/, '');
if (!url || !token) return res.status(400).json({ error: 'URL and token are required' });
try {
const response = await fetch(`${base}/message?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: '🦥 Sloth Manager — Test',
message: 'Gotify notifications are working correctly.',
priority: priority ?? 5,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return res.status(502).json({ error: `Gotify returned ${response.status}: ${text}` });
}
await testGotify({ url, token, priority });
res.json({ success: true });
} catch (err) {
res.status(502).json({ error: `Could not reach Gotify: ${err.message}` });
res.status(502).json({ error: err.message });
}
});
// POST /api/settings/test-ntfy
router.post('/test-ntfy', async (req, res) => {
const { url, topic, token, priority } = req.body;
if (!url || !topic) return res.status(400).json({ error: 'URL and topic are required' });
try {
await testNtfy({ url, topic, token, priority });
res.json({ success: true });
} catch (err) {
res.status(502).json({ error: err.message });
}
});
// POST /api/settings/test-smtp
router.post('/test-smtp', async (req, res) => {
const { host, port, secure, username, password, from, to } = req.body;
if (!host || !from || !to) return res.status(400).json({ error: 'Host, from, and to are required' });
try {
await testSmtp({ host, port, secure, username, password, from, to });
res.json({ success: true });
} catch (err) {
res.status(502).json({ error: err.message });
}
});
// POST /api/settings/test-webhook
router.post('/test-webhook', async (req, res) => {
const { url, secret } = req.body;
if (!url) return res.status(400).json({ error: 'Webhook URL is required' });
try {
await testWebhook({ url, secret });
res.json({ success: true });
} catch (err) {
res.status(502).json({ error: err.message });
}
});
+28
View File
@@ -18,6 +18,28 @@ const DEFAULTS = {
secret_check_time: '08:00',
timezone: 'UTC',
},
ntfy: {
enabled: false,
url: 'https://ntfy.sh',
topic: '',
token: '',
priority: 3,
},
smtp: {
enabled: false,
host: '',
port: 587,
secure: false,
username: '',
password: '',
from: '',
to: '',
},
webhook: {
enabled: false,
url: '',
secret: '',
},
providerColors: {
cloudflare: '#f6821f',
loopia: '#2ecc71',
@@ -36,6 +58,9 @@ function load() {
...raw,
gotify: { ...DEFAULTS.gotify, ...(raw.gotify ?? {}) },
notifications: { ...DEFAULTS.notifications, ...(raw.notifications ?? {}) },
ntfy: { ...DEFAULTS.ntfy, ...(raw.ntfy ?? {}) },
smtp: { ...DEFAULTS.smtp, ...(raw.smtp ?? {}) },
webhook: { ...DEFAULTS.webhook, ...(raw.webhook ?? {}) },
providerColors: { ...DEFAULTS.providerColors, ...(raw.providerColors ?? {}) },
};
} catch {
@@ -58,6 +83,9 @@ function update(partial) {
...partial,
gotify: { ...current.gotify, ...(partial.gotify ?? {}) },
notifications: { ...current.notifications, ...(partial.notifications ?? {}) },
ntfy: { ...current.ntfy, ...(partial.ntfy ?? {}) },
smtp: { ...current.smtp, ...(partial.smtp ?? {}) },
webhook: { ...current.webhook, ...(partial.webhook ?? {}) },
providerColors: { ...current.providerColors, ...(partial.providerColors ?? {}) },
};
save(merged);
+22 -1
View File
@@ -190,12 +190,33 @@ export async function clearCache() {
}
export async function testNotification(gotify) {
return handleResponse(await fetch(`${BASE}/settings/test-notification`, {
return handleResponse(await fetch(`${BASE}/settings/test-gotify`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(gotify),
}));
}
export async function testNtfyNotification(ntfy) {
return handleResponse(await fetch(`${BASE}/settings/test-ntfy`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(ntfy),
}));
}
export async function testSmtpNotification(smtp) {
return handleResponse(await fetch(`${BASE}/settings/test-smtp`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(smtp),
}));
}
export async function testWebhookNotification(webhook) {
return handleResponse(await fetch(`${BASE}/settings/test-webhook`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(webhook),
}));
}
export async function getSyncStatus(provider) {
return handleResponse(await fetch(`${BASE}/sync-status/${provider}`, { headers: authHeaders() }));
}
+137 -11
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { getSettings, saveSettings, testNotification, clearCache, getUsers, createUser, deleteUser, getProviderHealth } from '../api/dns';
import { getSettings, saveSettings, testNotification, testNtfyNotification, testSmtpNotification, testWebhookNotification, clearCache, getUsers, createUser, deleteUser, getProviderHealth } from '../api/dns';
import ConfirmDialog from './ConfirmDialog';
import { exportCsv } from '../utils/exportCsv';
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
@@ -16,18 +16,30 @@ const TABS = [
function NotificationsTab() {
const [form, setForm] = useState({ enabled: false, url: '', token: '', priority: 5 });
const [ntfy, setNtfy] = useState({ enabled: false, url: 'https://ntfy.sh', topic: '', token: '', priority: 3 });
const [smtp, setSmtp] = useState({ enabled: false, host: '', port: 587, secure: false, username: '', password: '', from: '', to: '' });
const [webhook, setWebhook] = useState({ enabled: false, url: '', secret: '' });
const [events, setEvents] = useState({ dns_add: true, dns_update: true, dns_delete: true, secret_check: true, secret_check_time: '08:00', timezone: 'UTC' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testingNtfy, setTestingNtfy] = useState(false);
const [testingSmtp, setTestingSmtp] = useState(false);
const [testingWebhook, setTestingWebhook] = useState(false);
const [saved, setSaved] = useState(false);
const [testResult, setTestResult] = useState(null);
const [testResult, setTestResult] = useState(null);
const [testResultNtfy, setTestResultNtfy] = useState(null);
const [testResultSmtp, setTestResultSmtp] = useState(null);
const [testResultWebhook, setTestResultWebhook] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
getSettings()
.then(s => {
setForm({ ...s.gotify });
setForm({ ...{ enabled: false, url: '', token: '', priority: 5 }, ...s.gotify });
setNtfy({ ...{ enabled: false, url: 'https://ntfy.sh', topic: '', token: '', priority: 3 }, ...s.ntfy });
setSmtp({ ...{ enabled: false, host: '', port: 587, secure: false, username: '', password: '', from: '', to: '' }, ...s.smtp });
setWebhook({ ...{ enabled: false, url: '', secret: '' }, ...s.webhook });
setEvents({ ...{ dns_add: true, dns_update: true, dns_delete: true, secret_check: true, secret_check_time: '08:00', timezone: 'UTC' }, ...s.notifications });
})
.catch(e => setError(e.message))
@@ -51,7 +63,7 @@ function NotificationsTab() {
e.preventDefault();
setSaving(true); setError(''); setSaved(false);
try {
await saveSettings({ gotify: { ...form, priority: Number(form.priority) }, notifications: events });
await saveSettings({ gotify: { ...form, priority: Number(form.priority) }, ntfy: { ...ntfy, priority: Number(ntfy.priority) }, smtp: { ...smtp, port: Number(smtp.port) }, webhook, notifications: events });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) { setError(err.message); }
@@ -68,6 +80,36 @@ function NotificationsTab() {
} finally { setTesting(false); }
}
async function handleTestNtfy() {
setTestingNtfy(true); setTestResultNtfy(null);
try {
await testNtfyNotification({ url: ntfy.url, topic: ntfy.topic, token: ntfy.token, priority: Number(ntfy.priority) });
setTestResultNtfy({ ok: true, message: 'Notification sent successfully.' });
} catch (err) {
setTestResultNtfy({ ok: false, message: err.message });
} finally { setTestingNtfy(false); }
}
async function handleTestSmtp() {
setTestingSmtp(true); setTestResultSmtp(null);
try {
await testSmtpNotification({ ...smtp, port: Number(smtp.port) });
setTestResultSmtp({ ok: true, message: 'Test email sent successfully.' });
} catch (err) {
setTestResultSmtp({ ok: false, message: err.message });
} finally { setTestingSmtp(false); }
}
async function handleTestWebhook() {
setTestingWebhook(true); setTestResultWebhook(null);
try {
await testWebhookNotification({ url: webhook.url, secret: webhook.secret });
setTestResultWebhook({ ok: true, message: 'Webhook delivered successfully.' });
} catch (err) {
setTestResultWebhook({ ok: false, message: err.message });
} finally { setTestingWebhook(false); }
}
if (loading) return <p className="hint">Loading</p>;
return (
@@ -167,14 +209,98 @@ function NotificationsTab() {
</section>
<section className="settings-section">
<h3>About Gotify</h3>
<h3>ntfy</h3>
<p className="settings-desc">Send notifications via <a href="https://ntfy.sh" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>ntfy.sh</a> or a self-hosted ntfy instance.</p>
<div className="settings-form">
<label className="toggle-row">
<span>Enable ntfy</span>
<input type="checkbox" checked={!!ntfy.enabled} onChange={e => setNtfy(f => ({ ...f, enabled: e.target.checked }))} className="toggle" />
</label>
<div className="form-row">
<label>Server URL<input value={ntfy.url} onChange={e => setNtfy(f => ({ ...f, url: e.target.value }))} placeholder="https://ntfy.sh" disabled={!ntfy.enabled} /></label>
<label>Topic<input value={ntfy.topic} onChange={e => setNtfy(f => ({ ...f, topic: e.target.value }))} placeholder="your-topic" disabled={!ntfy.enabled} /></label>
</div>
<div className="form-row">
<label>Access Token (optional)<input type="password" value={ntfy.token} onChange={e => setNtfy(f => ({ ...f, token: e.target.value }))} placeholder="For protected topics" disabled={!ntfy.enabled} /></label>
<label>Priority <span className="settings-hint">(15)</span><input type="number" min={1} max={5} value={ntfy.priority} onChange={e => setNtfy(f => ({ ...f, priority: e.target.value }))} disabled={!ntfy.enabled} /></label>
</div>
{testResultNtfy && <p className={testResultNtfy.ok ? 'test-ok' : 'test-fail'}>{testResultNtfy.ok ? '✓' : '✕'} {testResultNtfy.message}</p>}
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={handleTestNtfy} disabled={testingNtfy || !ntfy.url || !ntfy.topic}>{testingNtfy ? 'Sending…' : 'Send Test'}</button>
</div>
</div>
</section>
<section className="settings-section">
<h3>Email (SMTP)</h3>
<p className="settings-desc">Send notifications via email using any SMTP server.</p>
<div className="settings-form">
<label className="toggle-row">
<span>Enable SMTP</span>
<input type="checkbox" checked={!!smtp.enabled} onChange={e => setSmtp(f => ({ ...f, enabled: e.target.checked }))} className="toggle" />
</label>
<div className="form-row">
<label>SMTP Host<input value={smtp.host} onChange={e => setSmtp(f => ({ ...f, host: e.target.value }))} placeholder="smtp.example.com" disabled={!smtp.enabled} /></label>
<label>Port<input type="number" value={smtp.port} onChange={e => setSmtp(f => ({ ...f, port: e.target.value }))} placeholder="587" disabled={!smtp.enabled} /></label>
</div>
<label className="toggle-row" style={{ color: 'var(--text)', textTransform: 'none', letterSpacing: 0, fontSize: 14, fontWeight: 500 }}>
<span>Use TLS/SSL (port 465)</span>
<input type="checkbox" checked={!!smtp.secure} onChange={e => setSmtp(f => ({ ...f, secure: e.target.checked }))} className="toggle" disabled={!smtp.enabled} />
</label>
<div className="form-row">
<label>Username<input value={smtp.username} onChange={e => setSmtp(f => ({ ...f, username: e.target.value }))} placeholder="Optional" disabled={!smtp.enabled} /></label>
<label>Password<input type="password" value={smtp.password} onChange={e => setSmtp(f => ({ ...f, password: e.target.value }))} placeholder="Optional" disabled={!smtp.enabled} /></label>
</div>
<div className="form-row">
<label>From<input type="email" value={smtp.from} onChange={e => setSmtp(f => ({ ...f, from: e.target.value }))} placeholder="sloth@example.com" disabled={!smtp.enabled} /></label>
<label>To<input type="email" value={smtp.to} onChange={e => setSmtp(f => ({ ...f, to: e.target.value }))} placeholder="you@example.com" disabled={!smtp.enabled} /></label>
</div>
{testResultSmtp && <p className={testResultSmtp.ok ? 'test-ok' : 'test-fail'}>{testResultSmtp.ok ? '✓' : '✕'} {testResultSmtp.message}</p>}
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={handleTestSmtp} disabled={testingSmtp || !smtp.host || !smtp.from || !smtp.to}>{testingSmtp ? 'Sending…' : 'Send Test'}</button>
</div>
</div>
</section>
<section className="settings-section">
<h3>Webhook</h3>
<p className="settings-desc">
POST a JSON payload to any HTTP endpoint. Compatible with Discord, Slack, and custom receivers. For Discord use a <strong>Webhook URL</strong> from Server Settings Integrations Webhooks.
</p>
<div className="settings-form">
<label className="toggle-row">
<span>Enable Webhook</span>
<input type="checkbox" checked={!!webhook.enabled} onChange={e => setWebhook(f => ({ ...f, enabled: e.target.checked }))} className="toggle" />
</label>
<label>Webhook URL
<input type="url" value={webhook.url} onChange={e => setWebhook(f => ({ ...f, url: e.target.value }))} placeholder="https://discord.com/api/webhooks/…" disabled={!webhook.enabled} />
</label>
<label>Secret / Token <span className="settings-hint">(optional sent as X-Webhook-Secret header)</span>
<input type="password" value={webhook.secret} onChange={e => setWebhook(f => ({ ...f, secret: e.target.value }))} placeholder="Optional shared secret" disabled={!webhook.enabled} />
</label>
{testResultWebhook && <p className={testResultWebhook.ok ? 'test-ok' : 'test-fail'}>{testResultWebhook.ok ? '✓' : '✕'} {testResultWebhook.message}</p>}
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={handleTestWebhook} disabled={testingWebhook || !webhook.url}>{testingWebhook ? 'Sending…' : 'Send Test'}</button>
</div>
</div>
</section>
<section className="settings-section">
<h3>About Notifications</h3>
<p className="settings-desc" style={{ lineHeight: 1.8 }}>
<a href="https://gotify.net" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>Gotify</a> is a self-hosted push notification server.<br /><br />
<strong>To get started:</strong><br />
1. Log in to your Gotify instance<br />
2. Go to <strong>Apps Create application</strong><br />
3. Copy the generated token and paste it into App Token<br /><br />
Notifications fire on every record add, update, or delete not on sync.
Sloth Manager can deliver notifications through four independent channels enable as many as you need. All enabled channels receive the same events simultaneously.<br /><br />
<strong>Gotify</strong> — self-hosted push notification server. Create an application in your Gotify instance and paste the app token here. <a href="https://gotify.net" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>gotify.net</a><br /><br />
<strong>ntfy</strong> — open-source pub/sub push service. Use the public <a href="https://ntfy.sh" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>ntfy.sh</a> server or a self-hosted instance. Set a topic name and optionally an access token for protected topics.<br /><br />
<strong>Email (SMTP)</strong> send notifications via any SMTP server. TLS/SSL (port 465) and STARTTLS (port 587) are both supported. Username and password are optional for open relays.<br /><br />
<strong>Webhook</strong> POST a JSON payload to any HTTP endpoint. The body uses Discord's <code>{`{"content":""}`}</code> format, which is also accepted by many other services. An optional shared secret is sent as the <code>X-Webhook-Secret</code> header for request verification.<br /><br />
<strong>Notify on</strong> controls which events trigger a notification. DNS record events fire immediately. The secret expiry reminder runs once daily at the configured time and timezone, sending one message listing all expiring or expired secrets.<br /><br />
Sync operations never trigger notifications regardless of settings.
</p>
</section>
</div>