From 4d016b4156b4565feeccde8cefa18e243337f4aa Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Tue, 9 Jun 2026 17:59:54 +0000 Subject: [PATCH] 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 --- .github/workflows/version-bump.yml | 50 ++++++++++ db/migrations/009_email_templates.sql | 37 ++++++++ db/schema.sql | 37 ++++++++ panel/api/endpoints/system.php | 90 ++++++++++++++++++ panel/public/assets/js/admin.js | 127 +++++++++++++++++++++++--- 5 files changed, 329 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/version-bump.yml create mode 100644 db/migrations/009_email_templates.sql diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 0000000..ad608e7 --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -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 diff --git a/db/migrations/009_email_templates.sql b/db/migrations/009_email_templates.sql new file mode 100644 index 0000000..f4d336f --- /dev/null +++ b/db/migrations/009_email_templates.sql @@ -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', + '

Welcome, {{name}}!

Your hosting account for {{domain}} has been created.

Login: {{username}}
Password: {{password}}

Log in at {{panel_url}}

', + '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}}', + '

New account created

Domain: {{domain}}
User: {{username}}
Package: {{package}}
Created by: {{created_by}}

', + 'New account created: domain={{domain}} user={{username}} package={{package}}'), + ('account_suspended', 'Account Suspended (to user)', 'Your hosting account has been suspended', + '

Account Suspended

Your hosting account for {{domain}} has been suspended.

Reason: {{reason}}

Contact {{support_email}} to resolve this.

', + 'Your account for {{domain}} has been suspended. Reason: {{reason}}. Contact {{support_email}}.'), + ('account_terminated', 'Account Terminated (to user)', 'Your hosting account has been terminated', + '

Account Terminated

Your hosting account for {{domain}} has been permanently terminated. All data has been deleted.

', + 'Your account for {{domain}} has been permanently terminated.'), + ('password_reset', 'Password Reset', 'NovaCPX password reset request', + '

Password Reset

A password reset was requested for your account. Click below to reset your password:

Reset Password

This link expires in 1 hour. If you did not request this, ignore this email.

', + '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', + '

SSL Certificate Expiring Soon

The SSL certificate for {{domain}} will expire in {{days}} days on {{expiry_date}}.

Log in to renew: {{panel_url}}

', + 'SSL certificate for {{domain}} expires in {{days}} days on {{expiry_date}}.'), + ('disk_warning', 'Disk Usage Warning', 'Disk usage warning for {{domain}}: {{usage}}% used', + '

Disk Usage Warning

Your hosting account for {{domain}} is at {{usage}}% disk capacity ({{used}} of {{quota}} used).

Please free up space or upgrade your package.

', + 'Disk warning: {{domain}} is at {{usage}}% capacity ({{used}} of {{quota}}).'), + ('smtp_test', 'SMTP Test', 'NovaCPX SMTP Test Email', + '

SMTP Test Successful

This is a test email from NovaCPX. If you received this, your SMTP configuration is working correctly.

', + 'NovaCPX SMTP test email. SMTP is configured correctly.'); diff --git a/db/schema.sql b/db/schema.sql index f57581d..af8f395 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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_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', + '

Welcome, {{name}}!

Your hosting account for {{domain}} has been created.

Login: {{username}}
Password: {{password}}

Log in at {{panel_url}}

', + '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}}', + '

New account created

Domain: {{domain}}
User: {{username}}
Package: {{package}}
Created by: {{created_by}}

', + 'New account created: domain={{domain}} user={{username}} package={{package}}'), + ('account_suspended', 'Account Suspended (to user)', 'Your hosting account has been suspended', + '

Account Suspended

Your hosting account for {{domain}} has been suspended.

Reason: {{reason}}

Contact {{support_email}} to resolve this.

', + 'Your account for {{domain}} has been suspended. Reason: {{reason}}. Contact {{support_email}}.'), + ('account_terminated', 'Account Terminated (to user)', 'Your hosting account has been terminated', + '

Account Terminated

Your hosting account for {{domain}} has been permanently terminated. All data has been deleted.

', + 'Your account for {{domain}} has been permanently terminated.'), + ('password_reset', 'Password Reset', 'NovaCPX password reset request', + '

Password Reset

A password reset was requested for your account. Click below to reset your password:

Reset Password

This link expires in 1 hour. If you did not request this, ignore this email.

', + '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', + '

SSL Certificate Expiring Soon

The SSL certificate for {{domain}} will expire in {{days}} days on {{expiry_date}}.

Log in to renew: {{panel_url}}

', + 'SSL certificate for {{domain}} expires in {{days}} days on {{expiry_date}}.'), + ('disk_warning', 'Disk Usage Warning', 'Disk usage warning for {{domain}}: {{usage}}% used', + '

Disk Usage Warning

Your hosting account for {{domain}} is at {{usage}}% disk capacity ({{used}} of {{quota}} used).

Please free up space or upgrade your package.

', + 'Disk warning: {{domain}} is at {{usage}}% capacity ({{used}} of {{quota}}).'), + ('smtp_test', 'SMTP Test', 'NovaCPX SMTP Test Email', + '

SMTP Test Successful

This is a test email from NovaCPX. If you received this, your SMTP configuration is working correctly.

', + 'NovaCPX SMTP test email. SMTP is configured correctly.'); + -- ── API Tokens ──────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php index 92ad06e..07c5077 100644 --- a/panel/api/endpoints/system.php +++ b/panel/api/endpoints/system.php @@ -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) : '

Test

'; + $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'); diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 87b2ca4..a493db8 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -659,6 +659,7 @@ async function notifications() { const res = await Nova.api('system', 'notify-settings'); const s = res?.data || {}; + setTimeout(etLoadList, 80); return ` @@ -702,18 +703,12 @@
-
Notification Triggers
-
- - - - - - - - -
EventRecipientNotes
Account CreatedNew user + AdminSends welcome email with credentials
Account SuspendedAccount holder + AdminIncludes suspension reason
Disk Quota ≥85%Account holder + AdminOnce per day per account (cron)
SSL Expiry ≤14 daysAccount holder + AdminOnce per threshold per domain (cron)
-

Disk quota and SSL expiry checks run daily via cron.

+
+ Email Templates + +
+
+
Loading templates…
`; } @@ -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 = '
Loading templates…
'; + const r = await Nova.api('system', 'email-templates'); + const tmpls = r?.data?.templates || []; + if (!tmpls.length) { + body.innerHTML = '

No templates found. Create one.

'; + return; + } + body.innerHTML = `
+ + ${tmpls.map(t => ` + + + + + + `).join('')} +
TriggerLabelSubjectStatusActions
${Nova.escHtml(t.trigger_key)}${Nova.escHtml(t.label)}${Nova.escHtml(t.subject)}${t.enabled ? Nova.badge('enabled','green') : Nova.badge('disabled','red')} + + + +
`; + }; + + 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', ` +
+ +
+
+ +
+
+ +
+
+ +
`, + ` + ` + ); + }; + + window.etNew = () => { + Nova.modal('New Email Template', ` +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
`, + ` + ` + ); + }; + + 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 `