mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
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:
@@ -707,6 +707,96 @@ BASH;
|
||||
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 ────────────────────────────────────────────
|
||||
'db-engines' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin');
|
||||
|
||||
+115
-12
@@ -659,6 +659,7 @@
|
||||
async function notifications() {
|
||||
const res = await Nova.api('system', 'notify-settings');
|
||||
const s = res?.data || {};
|
||||
setTimeout(etLoadList, 80);
|
||||
return `
|
||||
<div class="page-header"><h2 class="page-title">Email Notifications</h2></div>
|
||||
|
||||
@@ -702,18 +703,12 @@
|
||||
</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 class="card-header">
|
||||
<span class="card-title">Email Templates</span>
|
||||
<button class="btn btn-sm btn-primary ml-auto" onclick="etNew()">+ New Template</button>
|
||||
</div>
|
||||
<div class="card-body" id="et-list-body">
|
||||
<div class="loading">Loading templates…</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -737,6 +732,114 @@
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
async function settings() {
|
||||
return `
|
||||
|
||||
Reference in New Issue
Block a user