diff --git a/panel/api/endpoints/accounts.php b/panel/api/endpoints/accounts.php index f4d2aaa..131ff69 100644 --- a/panel/api/endpoints/accounts.php +++ b/panel/api/endpoints/accounts.php @@ -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'); })(), diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php index 27233c1..d281eb7 100644 --- a/panel/api/endpoints/system.php +++ b/panel/api/endpoints/system.php @@ -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' => '
Email notifications are working correctly from your NovaCPX panel.
', + '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), }; diff --git a/panel/bin/notify-checks.php b/panel/bin/notify-checks.php new file mode 100644 index 0000000..a1a3eba --- /dev/null +++ b/panel/bin/notify-checks.php @@ -0,0 +1,70 @@ +> /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"; diff --git a/panel/lib/Notifier.php b/panel/lib/Notifier.php new file mode 100644 index 0000000..1ef944c --- /dev/null +++ b/panel/lib/Notifier.php @@ -0,0 +1,172 @@ +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(['Your hosting account has been created.
+| Domain: | {$domain} |
| Username: | {$user} |
| Password: | {$password} |
| Panel: | {$panel} |
Please change your password after first login.
" + ); + } + + // Notify admin + $adminEmail = self::adminEmail(); + if ($adminEmail) { + self::send($adminEmail, + "NovaCPX: New account created — {$domain}", + "A new hosting account was created:
+| Domain: | {$domain} |
| Username: | {$user} |
| Email: | {$email} |
Your hosting account {$domain} has been suspended.
+Reason: {$why}
+Please contact support to resolve this issue.
" + ); + } + + // Notify admin + $adminEmail = self::adminEmail(); + if ($adminEmail) { + self::send($adminEmail, + "NovaCPX: Account suspended — {$domain}", + "Account {$domain} (user: {$user}) was suspended.
+Reason: {$why}
" + ); + } + } + + 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}%", + "Your hosting account {$domain} has used {$pct}% of its disk quota.
+Usage: {$usedMb} MB of {$limitMb} MB
+Please free up space or contact support to upgrade your plan.
" + ); + } + + $adminEmail = self::adminEmail(); + if ($adminEmail) { + self::send($adminEmail, + "NovaCPX: Disk quota warning — {$domain} at {$pct}%", + "Account {$domain} is at {$pct}% disk usage ({$usedMb} MB / {$limitMb} MB).
" + ); + } + } + + 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}", + "The SSL certificate for {$domain} will expire in {$daysLeft} days.
+Please renew your certificate to avoid service interruption.
" + ); + } + + $adminEmail = self::adminEmail(); + if ($adminEmail) { + self::send($adminEmail, + "NovaCPX: SSL expiring in {$daysLeft} days — {$domain}", + "SSL for {$domain} expires in {$daysLeft} days. Account email: {$email}
" + ); + } + } +} diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index 8e79a46..80904cc 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -154,6 +154,10 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f); Server Options + + + Notifications + Settings diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 078270d..fdba64d 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -99,6 +99,7 @@ backups, cloudflare, 'server-options': serverOptions, + notifications, settings, }; @@ -480,6 +481,88 @@ `; } + // ── Notifications (#25) ─────────────────────────────────────────────────── + async function notifications() { + const res = await Nova.api('system', 'notify-settings'); + const s = res?.data || {}; + return ` +| Event | Recipient | Notes |
|---|---|---|
| Account Created | New user + Admin | Sends welcome email with credentials |
| Account Suspended | Account holder + Admin | Includes suspension reason |
| Disk Quota ≥85% | Account holder + Admin | Once per day per account (cron) |
| SSL Expiry ≤14 days | Account holder + Admin | Once per threshold per domain (cron) |
Disk quota and SSL expiry checks run daily via cron.
+