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', + '
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}}', + 'Domain: {{domain}}
User: {{username}}
Package: {{package}}
Created by: {{created_by}}
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', + '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', + 'A password reset was requested for your account. Click below to reset your 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', + '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', + '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', + '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', + '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}}', + 'Domain: {{domain}}
User: {{username}}
Package: {{package}}
Created by: {{created_by}}
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', + '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', + 'A password reset was requested for your account. Click below to reset your 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', + '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', + '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', + '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 `| 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.
+No templates found. Create one.
'; + return; + } + body.innerHTML = `| Trigger | Label | Subject | Status | Actions |
|---|---|---|---|---|
${Nova.escHtml(t.trigger_key)} |
+ ${Nova.escHtml(t.label)} | + +${t.enabled ? Nova.badge('enabled','green') : Nova.badge('disabled','red')} | ++ + + + | +