mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat(#25): email notifications via CyberMail
- Notifier.php: CyberMail API sender with 4 trigger types (account created, suspended, disk quota warning, SSL expiry) - Reads cybermail_api_key / notify_from_* / notify_admin_email from settings table - accounts.php: fires Notifier on create (welcome + admin alert) and suspend (user + admin alert) - system.php: notify-settings GET, save-notify-settings POST, test-notify POST (with API key masking) - bin/notify-checks.php: daily cron for disk ≥85% and SSL ≤14 days (flag-based dedup in settings table) - admin panel: Notifications page with form + trigger reference table; sidebar link added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ require_once NOVACPX_LIB . '/AccountManager.php';
|
||||
require_once NOVACPX_LIB . '/VhostManager.php';
|
||||
require_once NOVACPX_LIB . '/DNSManager.php';
|
||||
require_once NOVACPX_LIB . '/PHPManager.php';
|
||||
require_once NOVACPX_LIB . '/Notifier.php';
|
||||
|
||||
// Resellers can only see their own accounts
|
||||
$ownerId = $user['role'] === 'reseller' ? $user['uid'] : null;
|
||||
@@ -82,14 +83,21 @@ match ($action) {
|
||||
|
||||
$result = AccountManager::create($body);
|
||||
audit('account.create', $body['domain'], $result);
|
||||
// Send welcome email to user + admin notification
|
||||
Notifier::accountCreated(array_merge($body, ['email' => $body['email']]), $body['password']);
|
||||
Response::success($result, 'Account created successfully');
|
||||
})(),
|
||||
|
||||
'suspend' => (function() use ($db, $body, $ownerClause) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
$acct = $db->fetchOne("SELECT a.id FROM accounts a JOIN users u ON u.id = a.user_id WHERE a.id = ? $ownerClause", [$id]);
|
||||
$acct = $db->fetchOne(
|
||||
"SELECT a.id, a.username, a.domain, u.email FROM accounts a JOIN users u ON u.id = a.user_id WHERE a.id = ? $ownerClause",
|
||||
[$id]
|
||||
);
|
||||
if (!$acct) Response::error("Account not found", 404);
|
||||
AccountManager::suspend($id, $body['reason'] ?? '');
|
||||
$reason = $body['reason'] ?? '';
|
||||
AccountManager::suspend($id, $reason);
|
||||
Notifier::accountSuspended($acct, $reason);
|
||||
audit('account.suspend', "account:$id");
|
||||
Response::success(null, 'Account suspended');
|
||||
})(),
|
||||
|
||||
@@ -403,5 +403,73 @@ match ($action) {
|
||||
Response::success(null, "Setting saved: {$key} = {$value}");
|
||||
})(),
|
||||
|
||||
// ── Notification settings (#25) ───────────────────────────────────────────
|
||||
'notify-settings' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
$keys = ['cybermail_api_key','notify_from_email','notify_from_name','notify_admin_email','notifications_enabled'];
|
||||
$out = [];
|
||||
foreach ($db->fetchAll("SELECT `key`,`value` FROM settings WHERE `key` IN ('" . implode("','", $keys) . "')") as $r) {
|
||||
$out[$r['key']] = $r['value'];
|
||||
}
|
||||
// Mask API key for display
|
||||
if (!empty($out['cybermail_api_key'])) {
|
||||
$k = $out['cybermail_api_key'];
|
||||
$out['cybermail_api_key_masked'] = substr($k, 0, 10) . str_repeat('*', max(0, strlen($k) - 14)) . substr($k, -4);
|
||||
}
|
||||
Response::success($out);
|
||||
})(),
|
||||
|
||||
'save-notify-settings' => (function() use ($db, $body) {
|
||||
Auth::getInstance()->require('admin');
|
||||
$allowed = ['cybermail_api_key','notify_from_email','notify_from_name','notify_admin_email','notifications_enabled'];
|
||||
$saved = [];
|
||||
foreach ($allowed as $key) {
|
||||
if (!array_key_exists($key, $body)) continue;
|
||||
$value = trim($body[$key]);
|
||||
if ($key === 'cybermail_api_key' && str_contains($value, '***')) continue; // skip masked placeholder
|
||||
$db->execute(
|
||||
"INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)",
|
||||
[$key, $value]
|
||||
);
|
||||
$saved[] = $key;
|
||||
}
|
||||
audit('settings.notify', implode(',', $saved));
|
||||
Response::success(null, 'Notification settings saved');
|
||||
})(),
|
||||
|
||||
'test-notify' => (function() use ($db, $body) {
|
||||
Auth::getInstance()->require('admin');
|
||||
require_once NOVACPX_LIB . '/Notifier.php';
|
||||
$to = trim($body['to'] ?? '');
|
||||
if (!$to || !filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error("Valid email address required");
|
||||
// Send a test email directly
|
||||
$apiKey = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'cybermail_api_key'")['value'] ?? '';
|
||||
$fromEmail = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'notify_from_email'")['value'] ?: 'noreply@novacpx.local';
|
||||
$fromName = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = 'notify_from_name'")['value'] ?: 'NovaCPX Panel';
|
||||
if (!$apiKey) Response::error("No CyberMail API key configured");
|
||||
|
||||
$payload = json_encode([
|
||||
'from' => "$fromName <$fromEmail>",
|
||||
'to' => $to,
|
||||
'subject' => 'NovaCPX — test notification',
|
||||
'html' => '<h2>Test Notification</h2><p>Email notifications are working correctly from your NovaCPX panel.</p>',
|
||||
'text' => 'Test Notification: Email notifications are working correctly from your NovaCPX panel.',
|
||||
]);
|
||||
$ch = curl_init('https://platform.cyberpersons.com/email/v1/send');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 202) Response::success(null, "Test email sent to {$to}");
|
||||
else Response::error("CyberMail returned HTTP {$code}: " . substr($resp, 0, 200));
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown system action: $action", 404),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
/**
|
||||
* Cron: disk quota warnings + SSL expiry notifications
|
||||
* Cron: 0 6 * * * root /usr/bin/php /opt/novacpx/bin/notify-checks.php >> /var/log/novacpx/notify-checks.log 2>&1
|
||||
*/
|
||||
define('NOVACPX_ROOT', '/srv/novacpx/public');
|
||||
define('NOVACPX_LIB', NOVACPX_ROOT . '/lib');
|
||||
|
||||
require NOVACPX_ROOT . '/lib/Core.php';
|
||||
require NOVACPX_ROOT . '/lib/DB.php';
|
||||
require_once NOVACPX_LIB . '/AccountManager.php';
|
||||
require_once NOVACPX_LIB . '/Notifier.php';
|
||||
|
||||
$db = DB::getInstance();
|
||||
|
||||
// ── Disk quota warnings (>85% used) ──────────────────────────────────────────
|
||||
$accounts = $db->fetchAll(
|
||||
"SELECT a.id, a.username, a.domain, a.home_dir, u.email,
|
||||
p.disk_mb AS limit_mb
|
||||
FROM accounts a
|
||||
JOIN users u ON u.id = a.user_id
|
||||
LEFT JOIN packages p ON p.id = a.package_id
|
||||
WHERE a.status = 'active' AND p.disk_mb > 0"
|
||||
);
|
||||
|
||||
foreach ($accounts as $acct) {
|
||||
$usedMb = AccountManager::getDiskUsage($acct['home_dir']);
|
||||
$limitMb = (int)$acct['limit_mb'];
|
||||
if ($limitMb <= 0) continue;
|
||||
$pct = $usedMb / $limitMb * 100;
|
||||
if ($pct >= 85) {
|
||||
// Only send once per day — check flag in settings
|
||||
$flagKey = 'quota_warned_' . $acct['id'];
|
||||
$lastWarn = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = ?", [$flagKey]);
|
||||
$today = date('Y-m-d');
|
||||
if (($lastWarn['value'] ?? '') !== $today) {
|
||||
Notifier::diskQuotaWarning($acct, $usedMb, $limitMb);
|
||||
$db->execute(
|
||||
"INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)",
|
||||
[$flagKey, $today]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSL expiry warnings (<=14 days) ──────────────────────────────────────────
|
||||
$sslCerts = $db->fetchAll(
|
||||
"SELECT s.domain, s.expires_at, u.email
|
||||
FROM ssl_certs s
|
||||
JOIN accounts a ON a.id = s.account_id
|
||||
JOIN users u ON u.id = a.user_id
|
||||
WHERE s.status = 'active' AND s.expires_at IS NOT NULL
|
||||
AND s.expires_at <= DATE_ADD(NOW(), INTERVAL 14 DAY)
|
||||
AND s.expires_at > NOW()"
|
||||
);
|
||||
|
||||
foreach ($sslCerts as $cert) {
|
||||
$daysLeft = (int)ceil((strtotime($cert['expires_at']) - time()) / 86400);
|
||||
$flagKey = 'ssl_warned_' . md5($cert['domain']) . '_' . $daysLeft;
|
||||
$lastWarn = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = ?", [$flagKey]);
|
||||
if (!$lastWarn) {
|
||||
Notifier::sslExpiring($cert['domain'], $cert['email'], $daysLeft);
|
||||
$db->execute(
|
||||
"INSERT INTO settings (`key`,`value`) VALUES (?,NOW()) ON DUPLICATE KEY UPDATE `value`=NOW()",
|
||||
[$flagKey]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] notify-checks complete\n";
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* Email notification dispatcher via CyberMail API
|
||||
*/
|
||||
class Notifier {
|
||||
|
||||
private static function getSetting(string $key): string {
|
||||
$db = DB::getInstance();
|
||||
$row = $db->fetchOne("SELECT `value` FROM settings WHERE `key` = ?", [$key]);
|
||||
return $row['value'] ?? '';
|
||||
}
|
||||
|
||||
private static function send(string $to, string $subject, string $html): bool {
|
||||
$apiKey = self::getSetting('cybermail_api_key');
|
||||
$fromEmail = self::getSetting('notify_from_email') ?: 'noreply@novacpx.local';
|
||||
$fromName = self::getSetting('notify_from_name') ?: 'NovaCPX Panel';
|
||||
|
||||
if (!$apiKey || !$to) return false;
|
||||
|
||||
$payload = json_encode([
|
||||
'from' => "$fromName <$fromEmail>",
|
||||
'to' => $to,
|
||||
'subject' => $subject,
|
||||
'html' => $html,
|
||||
'text' => strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)),
|
||||
]);
|
||||
|
||||
$ch = curl_init('https://platform.cyberpersons.com/email/v1/send');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $code === 202;
|
||||
}
|
||||
|
||||
private static function adminEmail(): string {
|
||||
return self::getSetting('notify_admin_email');
|
||||
}
|
||||
|
||||
private static function notificationsEnabled(): bool {
|
||||
return self::getSetting('notifications_enabled') !== '0';
|
||||
}
|
||||
|
||||
// ── Triggers ──────────────────────────────────────────────────────────────
|
||||
|
||||
public static function accountCreated(array $account, string $password): void {
|
||||
if (!self::notificationsEnabled()) return;
|
||||
|
||||
$user = $account['username'];
|
||||
$domain = $account['domain'];
|
||||
$email = $account['email'] ?? '';
|
||||
$panel = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'your-panel');
|
||||
|
||||
// Notify the new user
|
||||
if ($email) {
|
||||
self::send($email,
|
||||
"Welcome to NovaCPX — your account is ready",
|
||||
"<h2>Welcome, {$user}!</h2>
|
||||
<p>Your hosting account has been created.</p>
|
||||
<table style='font-family:monospace;border-collapse:collapse'>
|
||||
<tr><td style='padding:4px 12px 4px 0'><strong>Domain:</strong></td><td>{$domain}</td></tr>
|
||||
<tr><td style='padding:4px 12px 4px 0'><strong>Username:</strong></td><td>{$user}</td></tr>
|
||||
<tr><td style='padding:4px 12px 4px 0'><strong>Password:</strong></td><td>{$password}</td></tr>
|
||||
<tr><td style='padding:4px 12px 4px 0'><strong>Panel:</strong></td><td><a href='{$panel}'>{$panel}</a></td></tr>
|
||||
</table>
|
||||
<p>Please change your password after first login.</p>"
|
||||
);
|
||||
}
|
||||
|
||||
// Notify admin
|
||||
$adminEmail = self::adminEmail();
|
||||
if ($adminEmail) {
|
||||
self::send($adminEmail,
|
||||
"NovaCPX: New account created — {$domain}",
|
||||
"<p>A new hosting account was created:</p>
|
||||
<table style='font-family:monospace;border-collapse:collapse'>
|
||||
<tr><td style='padding:4px 12px 4px 0'><strong>Domain:</strong></td><td>{$domain}</td></tr>
|
||||
<tr><td style='padding:4px 12px 4px 0'><strong>Username:</strong></td><td>{$user}</td></tr>
|
||||
<tr><td style='padding:4px 12px 4px 0'><strong>Email:</strong></td><td>{$email}</td></tr>
|
||||
</table>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function accountSuspended(array $account, string $reason = ''): void {
|
||||
if (!self::notificationsEnabled()) return;
|
||||
|
||||
$user = $account['username'] ?? 'unknown';
|
||||
$domain = $account['domain'] ?? 'unknown';
|
||||
$email = $account['email'] ?? '';
|
||||
$why = $reason ?: 'No reason provided';
|
||||
|
||||
// Notify account holder
|
||||
if ($email) {
|
||||
self::send($email,
|
||||
"Your hosting account has been suspended",
|
||||
"<h2>Account Suspended</h2>
|
||||
<p>Your hosting account <strong>{$domain}</strong> has been suspended.</p>
|
||||
<p><strong>Reason:</strong> {$why}</p>
|
||||
<p>Please contact support to resolve this issue.</p>"
|
||||
);
|
||||
}
|
||||
|
||||
// Notify admin
|
||||
$adminEmail = self::adminEmail();
|
||||
if ($adminEmail) {
|
||||
self::send($adminEmail,
|
||||
"NovaCPX: Account suspended — {$domain}",
|
||||
"<p>Account <strong>{$domain}</strong> (user: {$user}) was suspended.</p>
|
||||
<p><strong>Reason:</strong> {$why}</p>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function diskQuotaWarning(array $account, int $usedMb, int $limitMb): void {
|
||||
if (!self::notificationsEnabled()) return;
|
||||
|
||||
$pct = $limitMb > 0 ? round($usedMb / $limitMb * 100) : 0;
|
||||
$domain = $account['domain'] ?? 'unknown';
|
||||
$email = $account['email'] ?? '';
|
||||
|
||||
if ($email) {
|
||||
self::send($email,
|
||||
"Disk quota warning — {$domain} is at {$pct}%",
|
||||
"<h2>Disk Quota Warning</h2>
|
||||
<p>Your hosting account <strong>{$domain}</strong> has used <strong>{$pct}%</strong> of its disk quota.</p>
|
||||
<p>Usage: {$usedMb} MB of {$limitMb} MB</p>
|
||||
<p>Please free up space or contact support to upgrade your plan.</p>"
|
||||
);
|
||||
}
|
||||
|
||||
$adminEmail = self::adminEmail();
|
||||
if ($adminEmail) {
|
||||
self::send($adminEmail,
|
||||
"NovaCPX: Disk quota warning — {$domain} at {$pct}%",
|
||||
"<p>Account <strong>{$domain}</strong> is at <strong>{$pct}%</strong> disk usage ({$usedMb} MB / {$limitMb} MB).</p>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function sslExpiring(string $domain, string $email, int $daysLeft): void {
|
||||
if (!self::notificationsEnabled()) return;
|
||||
|
||||
if ($email) {
|
||||
self::send($email,
|
||||
"SSL certificate expiring in {$daysLeft} days — {$domain}",
|
||||
"<h2>SSL Certificate Expiry Notice</h2>
|
||||
<p>The SSL certificate for <strong>{$domain}</strong> will expire in <strong>{$daysLeft} days</strong>.</p>
|
||||
<p>Please renew your certificate to avoid service interruption.</p>"
|
||||
);
|
||||
}
|
||||
|
||||
$adminEmail = self::adminEmail();
|
||||
if ($adminEmail) {
|
||||
self::send($adminEmail,
|
||||
"NovaCPX: SSL expiring in {$daysLeft} days — {$domain}",
|
||||
"<p>SSL for <strong>{$domain}</strong> expires in <strong>{$daysLeft} days</strong>. Account email: {$email}</p>"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,10 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
Server Options
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="notifications">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
Notifications
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
backups,
|
||||
cloudflare,
|
||||
'server-options': serverOptions,
|
||||
notifications,
|
||||
settings,
|
||||
};
|
||||
|
||||
@@ -480,6 +481,88 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Notifications (#25) ───────────────────────────────────────────────────
|
||||
async function notifications() {
|
||||
const res = await Nova.api('system', 'notify-settings');
|
||||
const s = res?.data || {};
|
||||
return `
|
||||
<div class="page-header"><h2 class="page-title">Email Notifications</h2></div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><span class="card-title">CyberMail Settings</span></div>
|
||||
<div class="card-body">
|
||||
<form id="notify-form">
|
||||
<div class="grid-2">
|
||||
<div class="form-group" style="grid-column:1/-1">
|
||||
<label>CyberMail API Key</label>
|
||||
<input type="password" id="nf-apikey" name="cybermail_api_key" class="form-control" placeholder="${s.cybermail_api_key_masked || 'sk_live_…'}" value="">
|
||||
<span class="form-hint">Leave blank to keep existing key. Get your key from platform.cyberpersons.com</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>From Email</label>
|
||||
<input type="email" name="notify_from_email" class="form-control" value="${s.notify_from_email || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>From Name</label>
|
||||
<input type="text" name="notify_from_name" class="form-control" value="${s.notify_from_name || 'NovaCPX Panel'}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Admin Alert Email</label>
|
||||
<input type="email" name="notify_admin_email" class="form-control" value="${s.notify_admin_email || ''}">
|
||||
<span class="form-hint">Receives alerts for new accounts, suspensions, disk warnings</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notifications</label>
|
||||
<select name="notifications_enabled" class="form-control">
|
||||
<option value="1" ${(s.notifications_enabled ?? '1') !== '0' ? 'selected' : ''}>Enabled</option>
|
||||
<option value="0" ${s.notifications_enabled === '0' ? 'selected' : ''}>Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="notifyTest()">Send Test Email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Notification Triggers</span></div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>Event</th><th>Recipient</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Account Created</td><td>New user + Admin</td><td>Sends welcome email with credentials</td></tr>
|
||||
<tr><td>Account Suspended</td><td>Account holder + Admin</td><td>Includes suspension reason</td></tr>
|
||||
<tr><td>Disk Quota ≥85%</td><td>Account holder + Admin</td><td>Once per day per account (cron)</td></tr>
|
||||
<tr><td>SSL Expiry ≤14 days</td><td>Account holder + Admin</td><td>Once per threshold per domain (cron)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted text-sm" style="margin-top:.5rem">Disk quota and SSL expiry checks run daily via cron.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
document.addEventListener('submit', async e => {
|
||||
if (!e.target.matches('#notify-form')) return;
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const body = Object.fromEntries(fd.entries());
|
||||
if (!body.cybermail_api_key) delete body.cybermail_api_key;
|
||||
const res = await Nova.api('system', 'save-notify-settings', { method: 'POST', body });
|
||||
if (res?.success) Nova.toast('Notification settings saved', 'success');
|
||||
else Nova.toast(res?.message || 'Save failed', 'error');
|
||||
});
|
||||
|
||||
window.notifyTest = async () => {
|
||||
const email = prompt('Send test email to:');
|
||||
if (!email) return;
|
||||
const res = await Nova.api('system', 'test-notify', { method: 'POST', body: { to: email } });
|
||||
if (res?.success) Nova.toast(res.message, 'success');
|
||||
else Nova.toast(res?.message || 'Send failed', 'error');
|
||||
};
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
async function settings() {
|
||||
return `
|
||||
|
||||
Reference in New Issue
Block a user