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:
@@ -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
|
||||||
@@ -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.');
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 `
|
||||||
|
|||||||
Reference in New Issue
Block a user