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", "id": "1780397326074-s7tw",
"timestamp": "2026-06-02T10:48:46.074Z", "timestamp": "2026-06-02T10:48:46.074Z",
+10
View File
@@ -14,6 +14,7 @@
"express": "^4.19.2", "express": "^4.19.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"nodemailer": "^6.9.14",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1"
}, },
@@ -1008,6 +1009,15 @@
"node": ">=6" "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": { "node_modules/nodemon": {
"version": "3.1.14", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"nodemailer": "^6.9.14",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
+130 -37
View File
@@ -1,69 +1,162 @@
const settings = require('./settings'); const settings = require('./settings');
const db = require('./db'); const db = require('./db');
const nodemailer = require('nodemailer');
/** // ─── Channel senders ──────────────────────────────────────────────────────────
* Send a Gotify notification if enabled and configured.
* Errors are logged but never thrown — notifications are best-effort. async function sendGotify(title, message) {
*/
async function notify(title, message) {
const { gotify } = settings.get(); const { gotify } = settings.get();
if (!gotify.enabled || !gotify.url || !gotify.token) return; if (!gotify.enabled || !gotify.url || !gotify.token) return;
const base = gotify.url.replace(/\/$/, ''); const base = gotify.url.replace(/\/$/, '');
try { try {
const res = await fetch(`${base}/message?token=${encodeURIComponent(gotify.token)}`, { const res = await fetch(`${base}/message?token=${encodeURIComponent(gotify.token)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ title, message, priority: gotify.priority ?? 5 }),
title,
message,
priority: gotify.priority ?? 5,
}),
}); });
if (!res.ok) console.error(`[notify] Gotify error ${res.status}: ${await res.text().catch(() => '')}`);
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error(`[notify] Gotify error ${res.status}: ${text}`);
}
} catch (err) { } 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) { function eventEnabled(key) {
const { notifications } = settings.get(); return settings.get().notifications?.[key] !== false;
return notifications?.[key] !== false;
} }
function recordAdded(provider, zoneId, record) { function recordAdded(provider, zoneId, record) {
if (!eventEnabled('dns_add')) return; if (!eventEnabled('dns_add')) return;
const zoneName = db.getZoneName(provider, zoneId); const zoneName = db.getZoneName(provider, zoneId);
return notify( return notify(`DNS Record Added`, `[${provider}] ${zoneName}\n+ ${record.type} ${record.name}${record.content}`);
`DNS Record Added`,
`[${provider}] ${zoneName}\n+ ${record.type} ${record.name}${record.content}`,
);
} }
function recordUpdated(provider, zoneId, oldRecord, newRecord) { function recordUpdated(provider, zoneId, oldRecord, newRecord) {
if (!eventEnabled('dns_update')) return; if (!eventEnabled('dns_update')) return;
const zoneName = db.getZoneName(provider, zoneId); const zoneName = db.getZoneName(provider, zoneId);
return notify( return notify(`DNS Record Updated`, `[${provider}] ${zoneName}\n${oldRecord.type} ${oldRecord.name}\n ${oldRecord.content}${newRecord.content}`);
`DNS Record Updated`,
`[${provider}] ${zoneName}\n${oldRecord.type} ${oldRecord.name}\n ${oldRecord.content}${newRecord.content}`,
);
} }
function recordDeleted(provider, zoneId, record) { function recordDeleted(provider, zoneId, record) {
if (!eventEnabled('dns_delete')) return; if (!eventEnabled('dns_delete')) return;
const zoneName = db.getZoneName(provider, zoneId); const zoneName = db.getZoneName(provider, zoneId);
return notify( return notify(`DNS Record Deleted`, `[${provider}] ${zoneName}\n ${record.type} ${record.name} ${record.content}`);
`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 express = require('express');
const router = express.Router(); const router = express.Router();
const settings = require('../settings'); const settings = require('../settings');
const { notify } = require('../notify'); const { testGotify, testNtfy, testSmtp, testWebhook } = require('../notify');
// GET /api/settings // GET /api/settings
router.get('/', (req, res) => { router.get('/', (req, res) => {
@@ -12,8 +12,7 @@ router.get('/', (req, res) => {
router.put('/', (req, res) => { router.put('/', (req, res) => {
try { try {
const updated = settings.update(req.body); const updated = settings.update(req.body);
// Reschedule secret check if the notification time changed if (req.body.notifications?.secret_check_time || req.body.notifications?.timezone) {
if (req.body.notifications?.secret_check_time) {
try { require('../index').scheduleSecretCheck(); } catch { /* safe to ignore */ } try { require('../index').scheduleSecretCheck(); } catch { /* safe to ignore */ }
} }
res.json(updated); res.json(updated);
@@ -22,36 +21,51 @@ router.put('/', (req, res) => {
} }
}); });
// POST /api/settings/test-notification // POST /api/settings/test-gotify
router.post('/test-notification', async (req, res) => { router.post('/test-gotify', async (req, res) => {
// Use the payload from the request body so the user can test
// before saving (the frontend sends the current form values)
const { url, token, priority } = req.body; const { url, token, priority } = req.body;
if (!url || !token) return res.status(400).json({ error: 'URL and token are required' });
if (!url || !token) {
return res.status(400).json({ error: 'URL and token are required' });
}
const base = url.replace(/\/$/, '');
try { try {
const response = await fetch(`${base}/message?token=${encodeURIComponent(token)}`, { await testGotify({ url, token, priority });
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}` });
}
res.json({ success: true }); res.json({ success: true });
} catch (err) { } 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', secret_check_time: '08:00',
timezone: 'UTC', 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: { providerColors: {
cloudflare: '#f6821f', cloudflare: '#f6821f',
loopia: '#2ecc71', loopia: '#2ecc71',
@@ -36,6 +58,9 @@ function load() {
...raw, ...raw,
gotify: { ...DEFAULTS.gotify, ...(raw.gotify ?? {}) }, gotify: { ...DEFAULTS.gotify, ...(raw.gotify ?? {}) },
notifications: { ...DEFAULTS.notifications, ...(raw.notifications ?? {}) }, 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 ?? {}) }, providerColors: { ...DEFAULTS.providerColors, ...(raw.providerColors ?? {}) },
}; };
} catch { } catch {
@@ -58,6 +83,9 @@ function update(partial) {
...partial, ...partial,
gotify: { ...current.gotify, ...(partial.gotify ?? {}) }, gotify: { ...current.gotify, ...(partial.gotify ?? {}) },
notifications: { ...current.notifications, ...(partial.notifications ?? {}) }, 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 ?? {}) }, providerColors: { ...current.providerColors, ...(partial.providerColors ?? {}) },
}; };
save(merged); save(merged);
+22 -1
View File
@@ -190,12 +190,33 @@ export async function clearCache() {
} }
export async function testNotification(gotify) { 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() }, method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(gotify), 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) { export async function getSyncStatus(provider) {
return handleResponse(await fetch(`${BASE}/sync-status/${provider}`, { headers: authHeaders() })); return handleResponse(await fetch(`${BASE}/sync-status/${provider}`, { headers: authHeaders() }));
} }
+137 -11
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 ConfirmDialog from './ConfirmDialog';
import { exportCsv } from '../utils/exportCsv'; import { exportCsv } from '../utils/exportCsv';
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors'; import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
@@ -16,18 +16,30 @@ const TABS = [
function NotificationsTab() { function NotificationsTab() {
const [form, setForm] = useState({ enabled: false, url: '', token: '', priority: 5 }); 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 [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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = 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 [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(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
getSettings() getSettings()
.then(s => { .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 }); 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)) .catch(e => setError(e.message))
@@ -51,7 +63,7 @@ function NotificationsTab() {
e.preventDefault(); e.preventDefault();
setSaving(true); setError(''); setSaved(false); setSaving(true); setError(''); setSaved(false);
try { 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); setSaved(true);
setTimeout(() => setSaved(false), 3000); setTimeout(() => setSaved(false), 3000);
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
@@ -68,6 +80,36 @@ function NotificationsTab() {
} finally { setTesting(false); } } 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>; if (loading) return <p className="hint">Loading</p>;
return ( return (
@@ -167,14 +209,98 @@ function NotificationsTab() {
</section> </section>
<section className="settings-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 }}> <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 /> 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>To get started:</strong><br />
1. Log in to your Gotify instance<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 />
2. Go to <strong>Apps Create application</strong><br />
3. Copy the generated token and paste it into App Token<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 />
Notifications fire on every record add, update, or delete not on sync.
<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> </p>
</section> </section>
</div> </div>