Add notification email templates: DB migration, API CRUD, admin UI

- db/migrations/009_email_templates.sql: email_templates table with 8 default templates
- db/schema.sql: email_templates table added
- system.php: email-templates/get/save/delete/test actions with placeholder rendering
- admin.js: notifications page enhanced with template list, edit modal, CRUD, send test
- Templates support placeholders: {{name}}, {{domain}}, {{username}}, {{password}}, etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:59:54 +00:00
parent b295f8ca8e
commit 4d016b4156
5 changed files with 329 additions and 12 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Auto Version Bump
on:
push:
branches:
- main
- beta
paths-ignore:
- 'VERSION'
- '.github/**'
permissions:
contents: write
jobs:
bump:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Bump version
id: bump
run: |
BRANCH="${{ github.ref_name }}"
CURRENT=$(cat VERSION)
IFS='.' read -r MAJOR MINOR PATCH <<< "${CURRENT%%-*}"
PATCH=${PATCH:-0}
if [ "$BRANCH" = "main" ]; then
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
else
# beta branch: append -beta.N
BETA_NUM=$(echo "$CURRENT" | grep -oP '(?<=beta\.)\d+' || echo "0")
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}-beta.$((BETA_NUM + 1))"
fi
echo "$NEW_VERSION" > VERSION
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "Bumped $CURRENT → $NEW_VERSION"
- name: Commit version
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
git commit -m "chore: bump version to ${{ steps.bump.outputs.version }} [skip ci]"
git push
+37
View File
@@ -0,0 +1,37 @@
-- Migration 009: Email Templates
CREATE TABLE IF NOT EXISTS email_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trigger_key TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
subject TEXT NOT NULL,
body_html TEXT NOT NULL,
body_text TEXT,
enabled INTEGER DEFAULT 1,
updated_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO email_templates (trigger_key, label, subject, body_html, body_text) VALUES
('account_created', 'Account Created (to user)', 'Your NovaCPX hosting account is ready',
'<h2>Welcome, {{name}}!</h2><p>Your hosting account for <strong>{{domain}}</strong> has been created.</p><p><b>Login:</b> {{username}}<br><b>Password:</b> {{password}}</p><p>Log in at <a href="{{panel_url}}">{{panel_url}}</a></p>',
'Welcome, {{name}}! Your hosting account for {{domain}} has been created. Login: {{username}} / {{password}} at {{panel_url}}'),
('account_created_admin', 'Account Created (to admin)', 'New account created: {{domain}}',
'<h2>New account created</h2><p><b>Domain:</b> {{domain}}<br><b>User:</b> {{username}}<br><b>Package:</b> {{package}}<br><b>Created by:</b> {{created_by}}</p>',
'New account created: domain={{domain}} user={{username}} package={{package}}'),
('account_suspended', 'Account Suspended (to user)', 'Your hosting account has been suspended',
'<h2>Account Suspended</h2><p>Your hosting account for <strong>{{domain}}</strong> has been suspended.</p><p>Reason: {{reason}}</p><p>Contact <a href="mailto:{{support_email}}">{{support_email}}</a> to resolve this.</p>',
'Your account for {{domain}} has been suspended. Reason: {{reason}}. Contact {{support_email}}.'),
('account_terminated', 'Account Terminated (to user)', 'Your hosting account has been terminated',
'<h2>Account Terminated</h2><p>Your hosting account for <strong>{{domain}}</strong> has been permanently terminated. All data has been deleted.</p>',
'Your account for {{domain}} has been permanently terminated.'),
('password_reset', 'Password Reset', 'NovaCPX password reset request',
'<h2>Password Reset</h2><p>A password reset was requested for your account. Click below to reset your password:</p><p><a href="{{reset_url}}">Reset Password</a></p><p>This link expires in 1 hour. If you did not request this, ignore this email.</p>',
'Password reset requested. Visit {{reset_url}} to reset your password (expires in 1 hour).'),
('ssl_expiring', 'SSL Certificate Expiring', 'SSL certificate for {{domain}} expires in {{days}} days',
'<h2>SSL Certificate Expiring Soon</h2><p>The SSL certificate for <strong>{{domain}}</strong> will expire in <strong>{{days}} days</strong> on {{expiry_date}}.</p><p>Log in to renew: <a href="{{panel_url}}">{{panel_url}}</a></p>',
'SSL certificate for {{domain}} expires in {{days}} days on {{expiry_date}}.'),
('disk_warning', 'Disk Usage Warning', 'Disk usage warning for {{domain}}: {{usage}}% used',
'<h2>Disk Usage Warning</h2><p>Your hosting account for <strong>{{domain}}</strong> is at <strong>{{usage}}%</strong> disk capacity ({{used}} of {{quota}} used).</p><p>Please free up space or upgrade your package.</p>',
'Disk warning: {{domain}} is at {{usage}}% capacity ({{used}} of {{quota}}).'),
('smtp_test', 'SMTP Test', 'NovaCPX SMTP Test Email',
'<h2>SMTP Test Successful</h2><p>This is a test email from <strong>NovaCPX</strong>. If you received this, your SMTP configuration is working correctly.</p>',
'NovaCPX SMTP test email. SMTP is configured correctly.');
+37
View File
@@ -350,6 +350,43 @@ CREATE TABLE IF NOT EXISTS notifications (
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id); CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications (is_read); CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications (is_read);
-- ── Email Templates ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS email_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trigger_key TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
subject TEXT NOT NULL,
body_html TEXT NOT NULL,
body_text TEXT,
enabled INTEGER DEFAULT 1,
updated_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO email_templates (trigger_key, label, subject, body_html, body_text) VALUES
('account_created', 'Account Created (to user)', 'Your NovaCPX hosting account is ready',
'<h2>Welcome, {{name}}!</h2><p>Your hosting account for <strong>{{domain}}</strong> has been created.</p><p><b>Login:</b> {{username}}<br><b>Password:</b> {{password}}</p><p>Log in at <a href="{{panel_url}}">{{panel_url}}</a></p>',
'Welcome, {{name}}! Your hosting account for {{domain}} has been created. Login: {{username}} / {{password}} at {{panel_url}}'),
('account_created_admin', 'Account Created (to admin)', 'New account created: {{domain}}',
'<h2>New account created</h2><p><b>Domain:</b> {{domain}}<br><b>User:</b> {{username}}<br><b>Package:</b> {{package}}<br><b>Created by:</b> {{created_by}}</p>',
'New account created: domain={{domain}} user={{username}} package={{package}}'),
('account_suspended', 'Account Suspended (to user)', 'Your hosting account has been suspended',
'<h2>Account Suspended</h2><p>Your hosting account for <strong>{{domain}}</strong> has been suspended.</p><p>Reason: {{reason}}</p><p>Contact <a href="mailto:{{support_email}}">{{support_email}}</a> to resolve this.</p>',
'Your account for {{domain}} has been suspended. Reason: {{reason}}. Contact {{support_email}}.'),
('account_terminated', 'Account Terminated (to user)', 'Your hosting account has been terminated',
'<h2>Account Terminated</h2><p>Your hosting account for <strong>{{domain}}</strong> has been permanently terminated. All data has been deleted.</p>',
'Your account for {{domain}} has been permanently terminated.'),
('password_reset', 'Password Reset', 'NovaCPX password reset request',
'<h2>Password Reset</h2><p>A password reset was requested for your account. Click below to reset your password:</p><p><a href="{{reset_url}}">Reset Password</a></p><p>This link expires in 1 hour. If you did not request this, ignore this email.</p>',
'Password reset requested. Visit {{reset_url}} to reset your password (expires in 1 hour).'),
('ssl_expiring', 'SSL Certificate Expiring', 'SSL certificate for {{domain}} expires in {{days}} days',
'<h2>SSL Certificate Expiring Soon</h2><p>The SSL certificate for <strong>{{domain}}</strong> will expire in <strong>{{days}} days</strong> on {{expiry_date}}.</p><p>Log in to renew: <a href="{{panel_url}}">{{panel_url}}</a></p>',
'SSL certificate for {{domain}} expires in {{days}} days on {{expiry_date}}.'),
('disk_warning', 'Disk Usage Warning', 'Disk usage warning for {{domain}}: {{usage}}% used',
'<h2>Disk Usage Warning</h2><p>Your hosting account for <strong>{{domain}}</strong> is at <strong>{{usage}}%</strong> disk capacity ({{used}} of {{quota}} used).</p><p>Please free up space or upgrade your package.</p>',
'Disk warning: {{domain}} is at {{usage}}% capacity ({{used}} of {{quota}}).'),
('smtp_test', 'SMTP Test', 'NovaCPX SMTP Test Email',
'<h2>SMTP Test Successful</h2><p>This is a test email from <strong>NovaCPX</strong>. If you received this, your SMTP configuration is working correctly.</p>',
'NovaCPX SMTP test email. SMTP is configured correctly.');
-- ── API Tokens ──────────────────────────────────────────────────────────────── -- ── API Tokens ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS api_tokens ( CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+90
View File
@@ -707,6 +707,96 @@ BASH;
else Response::error("CyberMail returned HTTP {$code}: " . substr($resp, 0, 200)); else Response::error("CyberMail returned HTTP {$code}: " . substr($resp, 0, 200));
})(), })(),
// ── Email template CRUD ───────────────────────────────────────────────────
'email-templates' => (function() use ($db) {
Auth::getInstance()->require('admin');
$templates = $db->fetchAll("SELECT id,trigger_key,label,subject,enabled,updated_at FROM email_templates ORDER BY trigger_key");
Response::success(['templates' => $templates]);
})(),
'email-template-get' => (function() use ($db, $body) {
Auth::getInstance()->require('admin');
$id = (int)($body['id'] ?? $_GET['id'] ?? 0);
$row = $db->fetchOne("SELECT * FROM email_templates WHERE id = ?", [$id]);
if (!$row) Response::error("Template not found", 404);
Response::success($row);
})(),
'email-template-save' => (function() use ($db, $body) {
Auth::getInstance()->require('admin');
$id = (int)($body['id'] ?? 0);
$subject = trim($body['subject'] ?? '');
$bodyHtml = trim($body['body_html'] ?? '');
$bodyText = trim($body['body_text'] ?? '');
$enabled = isset($body['enabled']) ? (int)(bool)$body['enabled'] : 1;
if (!$subject || !$bodyHtml) Response::error("Subject and HTML body required");
if ($id) {
$db->execute("UPDATE email_templates SET subject=?,body_html=?,body_text=?,enabled=?,updated_at=datetime('now') WHERE id=?",
[$subject, $bodyHtml, $bodyText, $enabled, $id]);
} else {
$triggerKey = preg_replace('/[^a-z0-9_]/', '_', strtolower(trim($body['trigger_key'] ?? '')));
$label = trim($body['label'] ?? $triggerKey);
if (!$triggerKey) Response::error("trigger_key required for new template");
$id = (int)$db->insert("INSERT INTO email_templates (trigger_key,label,subject,body_html,body_text,enabled) VALUES (?,?,?,?,?,?)",
[$triggerKey, $label, $subject, $bodyHtml, $bodyText, $enabled]);
}
audit("email_template.save", (string)$id);
Response::success(['id' => $id], 'Template saved');
})(),
'email-template-delete' => (function() use ($db, $body) {
Auth::getInstance()->require('admin');
$id = (int)($body['id'] ?? 0);
$row = $db->fetchOne("SELECT trigger_key FROM email_templates WHERE id = ?", [$id]);
if (!$row) Response::error("Template not found", 404);
$db->execute("DELETE FROM email_templates WHERE id = ?", [$id]);
audit("email_template.delete", $row['trigger_key']);
Response::success(null, 'Template deleted');
})(),
'email-template-test' => (function() use ($db, $body) {
Auth::getInstance()->require('admin');
$id = (int)($body['id'] ?? 0);
$to = trim($body['to'] ?? '');
if (!$to || !filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error("Valid email address required");
$tmpl = $id ? $db->fetchOne("SELECT * FROM email_templates WHERE id = ?", [$id]) : null;
if (!$tmpl && $id) Response::error("Template not found", 404);
$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");
// Replace placeholders with sample values for test
$samples = [
'{{name}}' => 'Test User', '{{domain}}' => 'example.com', '{{username}}' => 'testuser',
'{{password}}' => '••••••••', '{{panel_url}}' => 'https://panel.yourdomain.com',
'{{reason}}' => 'Non-payment', '{{support_email}}' => $fromEmail,
'{{days}}' => '14', '{{expiry_date}}' => date('Y-m-d', strtotime('+14 days')),
'{{usage}}' => '87', '{{used}}' => '8.7 GB', '{{quota}}' => '10 GB',
'{{package}}' => 'Basic', '{{created_by}}' => 'admin',
'{{reset_url}}' => 'https://panel.yourdomain.com/reset?token=EXAMPLE',
];
$subject = $tmpl ? strtr($tmpl['subject'], $samples) : 'NovaCPX Test';
$html = $tmpl ? strtr($tmpl['body_html'], $samples) : '<p>Test</p>';
$text = $tmpl ? strtr($tmpl['body_text'] ?? '', $samples) : 'Test';
$payload = json_encode(['from' => $fromEmail, 'to' => $to, 'subject' => '[TEST] ' . $subject, 'html' => $html, 'text' => $text]);
$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));
})(),
// ── Database engine management ──────────────────────────────────────────── // ── Database engine management ────────────────────────────────────────────
'db-engines' => (function() use ($db) { 'db-engines' => (function() use ($db) {
Auth::getInstance()->require('admin'); Auth::getInstance()->require('admin');
+115 -12
View File
@@ -659,6 +659,7 @@
async function notifications() { async function notifications() {
const res = await Nova.api('system', 'notify-settings'); const res = await Nova.api('system', 'notify-settings');
const s = res?.data || {}; const s = res?.data || {};
setTimeout(etLoadList, 80);
return ` return `
<div class="page-header"><h2 class="page-title">Email Notifications</h2></div> <div class="page-header"><h2 class="page-title">Email Notifications</h2></div>
@@ -702,18 +703,12 @@
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"><span class="card-title">Notification Triggers</span></div> <div class="card-header">
<div class="card-body"> <span class="card-title">Email Templates</span>
<table class="table"> <button class="btn btn-sm btn-primary ml-auto" onclick="etNew()">+ New Template</button>
<thead><tr><th>Event</th><th>Recipient</th><th>Notes</th></tr></thead> </div>
<tbody> <div class="card-body" id="et-list-body">
<tr><td>Account Created</td><td>New user + Admin</td><td>Sends welcome email with credentials</td></tr> <div class="loading">Loading templates</div>
<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>
</div>`; </div>`;
} }
@@ -737,6 +732,114 @@
else Nova.toast(res?.message || 'Send failed', 'error'); else Nova.toast(res?.message || 'Send failed', 'error');
}; };
// ── Email Template Management ──────────────────────────────────────────────
window.etLoadList = async () => {
const body = document.getElementById('et-list-body');
if (!body) return;
body.innerHTML = '<div class="loading">Loading templates…</div>';
const r = await Nova.api('system', 'email-templates');
const tmpls = r?.data?.templates || [];
if (!tmpls.length) {
body.innerHTML = '<p class="text-muted">No templates found. <a href="#" onclick="etNew()">Create one</a>.</p>';
return;
}
body.innerHTML = `<div style="overflow-x:auto"><table class="table">
<thead><tr><th>Trigger</th><th>Label</th><th>Subject</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>${tmpls.map(t => `<tr>
<td><code style="font-size:.78rem">${Nova.escHtml(t.trigger_key)}</code></td>
<td>${Nova.escHtml(t.label)}</td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${Nova.escHtml(t.subject)}</td>
<td>${t.enabled ? Nova.badge('enabled','green') : Nova.badge('disabled','red')}</td>
<td style="white-space:nowrap">
<button class="btn btn-xs btn-ghost" onclick="etEdit(${t.id})">Edit</button>
<button class="btn btn-xs btn-ghost" onclick="etSendTest(${t.id})">Test</button>
<button class="btn btn-xs" style="color:var(--red)" onclick="etDelete(${t.id},'${Nova.escHtml(t.label)}')">Delete</button>
</td>
</tr>`).join('')}</tbody>
</table></div>`;
};
window.etEdit = async (id) => {
const r = await Nova.api('system', 'email-template-get', { method: 'POST', body: { id } });
if (!r?.success) { Nova.toast(r?.message || 'Load failed', 'error'); return; }
const t = r.data;
Nova.modal(id ? `Edit Template: ${t.label}` : 'New Template', `
<div class="form-group"><label>Subject</label>
<input id="et-subject" class="form-control" value="${Nova.escHtml(t.subject)}">
</div>
<div class="form-group"><label>HTML Body <span class="text-muted" style="font-size:.78rem"> use {{name}}, {{domain}}, {{username}}, etc.</span></label>
<textarea id="et-html" class="form-control" style="font-family:monospace;font-size:.8rem;height:220px">${Nova.escHtml(t.body_html)}</textarea>
</div>
<div class="form-group"><label>Plain Text Body <span class="text-muted" style="font-size:.78rem">(optional fallback)</span></label>
<textarea id="et-text" class="form-control" style="height:80px;font-size:.8rem">${Nova.escHtml(t.body_text || '')}</textarea>
</div>
<div class="form-group"><label>Status</label>
<select id="et-enabled" class="form-control" style="width:auto">
<option value="1" ${t.enabled ? 'selected' : ''}>Enabled</option>
<option value="0" ${!t.enabled ? 'selected' : ''}>Disabled</option>
</select>
</div>`,
`<button class="btn btn-primary" onclick="etSave(${id})">Save Template</button>
<button class="btn btn-ghost" onclick="document.querySelector('.modal-overlay')?.remove()">Cancel</button>`
);
};
window.etNew = () => {
Nova.modal('New Email Template', `
<div class="form-group"><label>Trigger Key <span class="text-muted" style="font-size:.78rem">(snake_case, unique)</span></label>
<input id="et-trigger" class="form-control" placeholder="e.g. account_upgraded">
</div>
<div class="form-group"><label>Label</label>
<input id="et-label" class="form-control" placeholder="Friendly name">
</div>
<div class="form-group"><label>Subject</label>
<input id="et-subject" class="form-control" placeholder="Email subject line">
</div>
<div class="form-group"><label>HTML Body</label>
<textarea id="et-html" class="form-control" style="font-family:monospace;font-size:.8rem;height:200px" placeholder="<h2>Hello {{name}}</h2><p>...</p>"></textarea>
</div>
<div class="form-group"><label>Plain Text Body <span class="text-muted" style="font-size:.78rem">(optional)</span></label>
<textarea id="et-text" class="form-control" style="height:70px;font-size:.8rem"></textarea>
</div>`,
`<button class="btn btn-primary" onclick="etSave(0)">Create Template</button>
<button class="btn btn-ghost" onclick="document.querySelector('.modal-overlay')?.remove()">Cancel</button>`
);
};
window.etSave = async (id) => {
const subject = document.getElementById('et-subject')?.value?.trim();
const body_html = document.getElementById('et-html')?.value?.trim();
const body_text = document.getElementById('et-text')?.value?.trim();
const enabled = document.getElementById('et-enabled')?.value ?? '1';
if (!subject || !body_html) { Nova.toast('Subject and HTML body required', 'error'); return; }
const extra = id ? {} : {
trigger_key: document.getElementById('et-trigger')?.value?.trim(),
label: document.getElementById('et-label')?.value?.trim(),
};
const r = await Nova.api('system', 'email-template-save', { method: 'POST', body: { id, subject, body_html, body_text, enabled, ...extra } });
if (r?.success) {
Nova.toast('Template saved', 'success');
document.querySelector('.modal-overlay')?.remove();
etLoadList();
} else { Nova.toast(r?.message || 'Save failed', 'error'); }
};
window.etDelete = (id, label) => {
Nova.confirm(`Delete template "${label}"? This cannot be undone.`, async () => {
const r = await Nova.api('system', 'email-template-delete', { method: 'POST', body: { id } });
if (r?.success) { Nova.toast('Template deleted', 'success'); etLoadList(); }
else Nova.toast(r?.message || 'Delete failed', 'error');
}, true);
};
window.etSendTest = async (id) => {
const email = prompt('Send test email to:');
if (!email) return;
const r = await Nova.api('system', 'email-template-test', { method: 'POST', body: { id, to: email } });
if (r?.success) Nova.toast(r.message, 'success');
else Nova.toast(r?.message || 'Send failed', 'error');
};
// ── Settings ─────────────────────────────────────────────────────────────── // ── Settings ───────────────────────────────────────────────────────────────
async function settings() { async function settings() {
return ` return `