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:
2026-06-08 04:12:47 +00:00
parent 33c36ffc65
commit 2ab74b7569
6 changed files with 407 additions and 2 deletions
+4
View File
@@ -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
+83
View File
@@ -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 `