Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/admin_emails.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/admin_emails.cgi

added on local at 2026-07-11 18:38:32

Added
+565
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to cbb15010a579
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# AffSoft -- /admin_emails.cgi
4#
5# List + edit + preview + send-test email templates from the
6# email_templates table. Super-admin only.
7#
8# GET list templates (or ?edit=<slug> to edit one)
9# POST act=save update subject/body
10# POST act=send_test send a rendered copy to the admin doing the test
11#======================================================================
12use strict;
13use warnings;
14
15use lib '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com';
16use CGI;
17use MODS::DBConnect;
18use MODS::AffSoft::Wrapper;
19use MODS::AffSoft::Config;
20use MODS::Login;
21use MODS::AffSoft::Mailer;
22
23my $q = CGI->new;
24my $form = $q->Vars;
25my $wrap = MODS::AffSoft::Wrapper->new;
26my $db = MODS::DBConnect->new;
27my $cfg = MODS::AffSoft::Config->new;
28my $DB = $cfg->settings('database_name');
29
30my $auth = MODS::Login->new;
31my $u = $auth->login_verify;
32unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
33require MODS::AffSoft::Permissions;
34MODS::AffSoft::Permissions->new->require_super_admin($u);
35my $uid = $u->{user_id} + 0;
36
37my $dbh = $db->db_connect;
38my $act = $form->{act} || '';
39my $flash = '';
40
41# ---- POST handlers --------------------------------------------------
42if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
43 if ($act eq 'save') {
44 my $tid = ($form->{template_id} || 0) + 0;
45 my $name = substr($form->{name} || '', 0, 160); $name =~ s/'/''/g;
46 my $subj = substr($form->{subject} || '', 0, 255); $subj =~ s/'/''/g;
47 my $body = substr($form->{body_html} || '', 0, 65000); $body =~ s/'/''/g;
48 my $cat = $form->{category} || 'lifecycle';
49 $cat = 'lifecycle' unless $cat =~ /^(transactional|lifecycle|product|marketing)$/;
50 my $active = $form->{is_active} ? 1 : 0;
51 $db->db_readwrite($dbh, qq~
52 UPDATE ${DB}.email_templates
53 SET name='$name', subject='$subj', body_html='$body',
54 category='$cat', is_active='$active',
55 updated_by_user_id='$uid'
56 WHERE id='$tid'
57 ~, $0, __LINE__);
58 $flash = 'Saved.';
59 }
60 elsif ($act eq 'send_test') {
61 my $slug = $form->{slug} || '';
62 $slug =~ s/[^a-z0-9_-]//g;
63 my $to = $form->{test_to} || $u->{email} || '';
64 my $mailer = MODS::AffSoft::Mailer->new;
65 my %sample = (
66 display_name => $u->{display_name} || 'Test User',
67 first_name => (split /\s+/, ($u->{display_name} || 'Test'))[0],
68 company_name => $u->{company_name} || 'Test Workspace',
69 trial_ends_at => '2026-07-04 09:00:00',
70 days_left => 7,
71 inviter_name => 'You',
72 inviter_email => $u->{email} || '',
73 accept_url => 'https://affiliate.3dshawn.com/accept_invite.cgi?token=SAMPLE',
74 reset_url => 'https://affiliate.3dshawn.com/reset_password.cgi?t=SAMPLE',
75 );
76 my $ok = $mailer->send_template(
77 slug => $slug,
78 user_id => $uid,
79 to => $to,
80 vars => \%sample,
81 );
82 $flash = $ok ? "Test sent to $to." : "Test failed -- check _mail.log and email_sends_log.";
83 }
84}
85
86# ---- GET (list or edit) ---------------------------------------------
87my $edit_slug = $form->{edit} || '';
88$edit_slug =~ s/[^a-z0-9_-]//g;
89
90my @templates = $db->db_readwrite_multiple($dbh, qq~
91 SELECT t.id, t.slug, t.category, t.name, t.subject, t.body_html,
92 t.is_system, t.is_active, t.updated_at,
93 (SELECT COUNT(*) FROM ${DB}.email_schedules WHERE template_id=t.id AND is_active=1) AS sched_count
94 FROM ${DB}.email_templates t
95 ORDER BY t.category, t.slug
96~, $0, __LINE__);
97
98# Last 20 sends for the activity panel
99my @recent_sends = $db->db_readwrite_multiple($dbh, qq~
100 SELECT id, user_id, template_slug, to_email, subject, status, failure_reason, created_at,
101 UNIX_TIMESTAMP(created_at) AS created_at_epoch
102 FROM ${DB}.email_sends_log ORDER BY id DESC LIMIT 20
103~, $0, __LINE__);
104
105
106$db->db_disconnect($dbh);
107
108print "Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n";
109
110my $body;
111my $flash_html = $flash ? qq~<div style="padding:10px 14px;background:rgba(34,197,94,.12);border:1px solid rgba(34,197,94,.3);border-radius:8px;color:#86efac;font-size:13px;margin-bottom:18px">$flash</div>~ : '';
112
113if ($edit_slug) {
114 my ($t) = grep { $_->{slug} eq $edit_slug } @templates;
115 if ($t) {
116 my $name = _h($t->{name});
117 my $subj = _h($t->{subject});
118 my $body_h = _h($t->{body_html});
119 my $cat = $t->{category};
120 my $checked = $t->{is_active} ? 'checked' : '';
121 my $is_sys = $t->{is_system} ? '<span style="font-size:11px;padding:2px 8px;border-radius:4px;background:rgba(139,92,246,.18);color:#c4b5fd;margin-left:8px">SYSTEM</span>' : '';
122 my @opts;
123 foreach my $k (qw(transactional lifecycle product marketing)) {
124 my $sel = ($k eq $cat) ? ' selected' : '';
125 push @opts, qq~<option value="$k"$sel>$k</option>~;
126 }
127 my $opt_html = join('', @opts);
128 my $tabs = _email_setup_tabs('templates');
129 $body = qq~
130<div class="page-pad">
131 <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email Setup</h1>
132 <p style="color:#94a3b8;margin:0 0 18px;font-size:14px">Templates, schedules, and spam-protection controls.</p>
133 $tabs
134 <a href="/admin_emails.cgi" style="color:#94a3b8;font-size:13px;text-decoration:none">&larr; All templates</a>
135 <h2 style="margin:8px 0 6px;font-size:22px;color:#fff">Edit: $name $is_sys</h2>
136 <p style="color:#94a3b8;margin:0 0 24px;font-size:14px"><code style="color:#c4b5fd">$t->{slug}</code> &middot; category <strong>$cat</strong></p>
137 $flash_html
138 <form method="POST" action="/admin_emails.cgi" style="display:grid;grid-template-columns:1fr 280px;gap:24px">
139 <div>
140 <input type="hidden" name="act" value="save">
141 <input type="hidden" name="template_id" value="$t->{id}">
142 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Name</label>
143 <input class="input" type="text" name="name" value="$name" maxlength="160" style="width:100%;margin-bottom:14px">
144 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Category</label>
145 <select class="select" name="category" style="width:100%;margin-bottom:14px">$opt_html</select>
146 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Subject</label>
147 <input class="input" type="text" name="subject" value="$subj" maxlength="255" style="width:100%;margin-bottom:14px">
148 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Body</label>
149 <div class="wyse" id="wyse" style="margin-bottom:14px">
150 <div class="wyse-toolbar">
151 <button type="button" class="wbtn" data-cmd="bold" title="Bold (Ctrl+B)"><b>B</b></button>
152 <button type="button" class="wbtn" data-cmd="italic" title="Italic (Ctrl+I)"><i>I</i></button>
153 <button type="button" class="wbtn" data-cmd="underline" title="Underline"><u>U</u></button>
154 <button type="button" class="wbtn" data-cmd="strikeThrough" title="Strikethrough"><s>S</s></button>
155 <span class="wsep"></span>
156 <button type="button" class="wbtn wbtn-text" data-block="h1" title="Heading 1">H1</button>
157 <button type="button" class="wbtn wbtn-text" data-block="h2" title="Heading 2">H2</button>
158 <button type="button" class="wbtn wbtn-text" data-block="h3" title="Heading 3">H3</button>
159 <button type="button" class="wbtn wbtn-text" data-block="p" title="Paragraph">P</button>
160 <span class="wsep"></span>
161 <button type="button" class="wbtn" data-cmd="insertUnorderedList" title="Bulleted list">&bull;</button>
162 <button type="button" class="wbtn" data-cmd="insertOrderedList" title="Numbered list">1.</button>
163 <button type="button" class="wbtn wbtn-text" data-block="blockquote" title="Block quote">&ldquo;</button>
164 <button type="button" class="wbtn wbtn-text" data-action="code" title="Inline code">&lt;/&gt;</button>
165 <span class="wsep"></span>
166 <button type="button" class="wbtn wbtn-text" data-action="link" title="Insert link">Link</button>
167 <button type="button" class="wbtn" data-cmd="unlink" title="Remove link">&#128279;</button>
168 <button type="button" class="wbtn wbtn-text" data-cmd="insertHorizontalRule" title="Insert horizontal rule">&mdash;</button>
169 <span class="wsep"></span>
170 <select class="wsel" id="wyse-var" title="Insert a template variable at the cursor">
171 <option value="">+ Variable</option>
172 <option value="{{display_name}}">display_name</option>
173 <option value="{{first_name}}">first_name</option>
174 <option value="{{company_name}}">company_name</option>
175 <option value="{{trial_ends_at}}">trial_ends_at</option>
176 <option value="{{days_left}}">days_left</option>
177 <option value="{{billing_url}}">billing_url</option>
178 <option value="{{dashboard_url}}">dashboard_url</option>
179 <option value="{{unsubscribe_url}}">unsubscribe_url</option>
180 <option value="{{inviter_name}}">inviter_name</option>
181 <option value="{{inviter_email}}">inviter_email</option>
182 <option value="{{accept_url}}">accept_url</option>
183 <option value="{{reset_url}}">reset_url</option>
184 </select>
185 <span class="wsep" style="margin-left:auto"></span>
186 <button type="button" class="wbtn wbtn-text wbtn-preview" id="wyse-preview" title="Preview the email with sample values substituted">Preview &#9654;</button>
187 <button type="button" class="wbtn wbtn-text wbtn-mode" id="wyse-mode" title="Toggle between rendered HTML and raw source">Source &#8644;</button>
188 </div>
189 <div class="wyse-rendered" id="wyse-rendered" contenteditable="true" spellcheck="true"></div>
190 <textarea class="wyse-source" id="wyse-source" style="display:none">$body_h</textarea>
191 </div>
192 <input type="hidden" name="body_html" id="wyse-hidden">
193 <div class="wprev-backdrop" id="wprev-backdrop" role="dialog" aria-modal="true">
194 <div class="wprev-modal">
195 <div class="wprev-head">
196 <div class="wprev-title">Preview &middot; sample values substituted</div>
197 <button type="button" class="wprev-close" id="wprev-close">Close</button>
198 </div>
199 <div class="wprev-meta">
200 <div class="wprev-from">From: AffSoft &lt;noreply\@affiliate.3dshawn.com&gt; &middot; To: shawn\@example.com</div>
201 <div class="wprev-subject" id="wprev-subject"></div>
202 </div>
203 <div class="wprev-body" id="wprev-body"></div>
204 </div>
205 </div>
206 <style>
207 .wyse { border:1px solid rgba(255,255,255,.10); border-radius:10px; overflow:hidden; background:#0f1420; }
208 .wyse-toolbar { display:flex; flex-wrap:wrap; gap:2px; padding:8px 10px; background:#171b2a; border-bottom:1px solid rgba(255,255,255,.10); align-items:center; }
209 .wyse .wbtn { background:transparent; border:1px solid transparent; color:#cbd5e1; font-size:13px; min-width:32px; height:30px; padding:0 8px; border-radius:6px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; line-height:1; font-family:inherit; }
210 .wyse .wbtn:hover { background:rgba(255,255,255,.06); color:#fff; }
211 .wyse .wbtn.is-active { background:rgba(90,169,255,.20); color:#bfdbff; border-color:rgba(90,169,255,.35); }
212 .wyse .wbtn-text { font-size:12px; font-weight:600; }
213 .wyse .wsep { width:1px; height:18px; background:rgba(255,255,255,.10); margin:0 6px; }
214 .wyse .wsel { background:#0f1420; color:#cbd5e1; border:1px solid rgba(255,255,255,.12); border-radius:6px; padding:5px 8px; font-size:12px; cursor:pointer; height:30px; }
215 .wyse .wbtn-mode { background:rgba(139,92,246,.15); color:#c4b5fd; border-color:rgba(139,92,246,.30); }
216 .wyse .wbtn-mode:hover { background:rgba(139,92,246,.25); }
217 .wyse .wbtn-preview { background:rgba(34,197,94,.14); color:#86efac; border-color:rgba(34,197,94,.30); }
218 .wyse .wbtn-preview:hover { background:rgba(34,197,94,.24); }
219 .wprev-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.65); z-index:9000; display:none; align-items:flex-start; justify-content:center; padding:40px 16px; overflow:auto; }
220 .wprev-backdrop.is-open { display:flex; }
221 .wprev-modal { background:#0b0f1a; color:#e2e8f0; max-width:720px; width:100%; border:1px solid rgba(255,255,255,.10); border-radius:14px; overflow:hidden; box-shadow:0 20px 60px rgba(0,0,0,.5); }
222 .wprev-head { display:flex; align-items:center; padding:14px 20px; border-bottom:1px solid rgba(255,255,255,.10); background:#171b2a; gap:14px; }
223 .wprev-head .wprev-title { font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:#94a3b8; font-weight:600; }
224 .wprev-head .wprev-close { margin-left:auto; background:transparent; border:1px solid rgba(255,255,255,.12); color:#cbd5e1; padding:6px 14px; border-radius:6px; cursor:pointer; font-size:13px; }
225 .wprev-head .wprev-close:hover { background:rgba(255,255,255,.06); }
226 .wprev-meta { padding:12px 20px; font-size:12px; color:#94a3b8; border-bottom:1px solid rgba(255,255,255,.05); background:rgba(20,20,30,.3); }
227 .wprev-meta .wprev-from { color:#cbd5e1; font-weight:500; }
228 .wprev-meta .wprev-subject { color:#f1f5f9; font-weight:700; font-size:15px; margin-top:6px; }
229 .wprev-body { padding:24px 28px; line-height:1.65; font-size:14px; max-height:60vh; overflow:auto; }
230 .wprev-body p { margin:.6em 0; }
231 .wprev-body h1 { font-size:22px; margin:.4em 0 .3em; color:#f1f5f9; }
232 .wprev-body h2 { font-size:18px; margin:.5em 0 .3em; color:#f1f5f9; }
233 .wprev-body h3 { font-size:16px; margin:.5em 0 .3em; color:#f1f5f9; }
234 .wprev-body ul, .wprev-body ol { padding-left:1.6em; margin:.5em 0; }
235 .wprev-body a { color:#5aa9ff; text-decoration:underline; }
236 .wprev-body code { background:rgba(255,255,255,.06); padding:1px 6px; border-radius:4px; font-family:ui-monospace,Consolas,Menlo,monospace; font-size:12.5px; }
237 .wprev-body blockquote { border-left:3px solid rgba(255,255,255,.20); margin:.8em 0; padding:.2em 1em; color:#cbd5e1; }
238 .wprev-body hr { border:0; border-top:1px solid rgba(255,255,255,.15); margin:1em 0; }
239 .wyse-rendered { min-height:380px; padding:16px 20px; color:#e2e8f0; font-size:14px; line-height:1.6; outline:none; background:#0b0f1a; }
240 .wyse-rendered:focus { background:#0b0f1a; }
241 .wyse-rendered h1 { font-size:22px; margin:.4em 0 .3em; color:#f1f5f9; }
242 .wyse-rendered h2 { font-size:18px; margin:.5em 0 .3em; color:#f1f5f9; }
243 .wyse-rendered h3 { font-size:16px; margin:.5em 0 .3em; color:#f1f5f9; }
244 .wyse-rendered p { margin:.5em 0; }
245 .wyse-rendered ul, .wyse-rendered ol { padding-left:1.6em; margin:.5em 0; }
246 .wyse-rendered a { color:#5aa9ff; text-decoration:underline; }
247 .wyse-rendered code { background:rgba(255,255,255,.06); padding:1px 6px; border-radius:4px; font-family:ui-monospace,Consolas,Menlo,monospace; font-size:12.5px; }
248 .wyse-rendered blockquote { border-left:3px solid rgba(255,255,255,.20); margin:.8em 0; padding:.2em 1em; color:#cbd5e1; }
249 .wyse-rendered hr { border:0; border-top:1px solid rgba(255,255,255,.15); margin:1em 0; }
250 .wyse-source { width:100%; min-height:380px; padding:14px 18px; background:#070a13; color:#cbd5e1; border:0; outline:none; font-family:ui-monospace,Consolas,Menlo,monospace; font-size:12.5px; resize:vertical; box-sizing:border-box; }
251 </style>
252 <script>
253 (function () {
254 var root = document.getElementById('wyse');
255 var rendered= document.getElementById('wyse-rendered');
256 var source = document.getElementById('wyse-source');
257 var hidden = document.getElementById('wyse-hidden');
258 var modeBtn = document.getElementById('wyse-mode');
259 var varSel = document.getElementById('wyse-var');
260 if (!root || !rendered || !source) return;
261
262 var mode = 'rendered';
263
264 // Hydrate the rendered pane from the source HTML on load.
265 rendered.innerHTML = source.value;
266 hidden.value = source.value;
267
268 function syncHidden() {
269 if (mode === 'rendered') {
270 source.value = rendered.innerHTML;
271 } else {
272 rendered.innerHTML = source.value;
273 }
274 hidden.value = source.value;
275 }
276
277 // Toolbar formatting buttons via execCommand (legacy but ubiquitous).
278 root.querySelectorAll('.wbtn[data-cmd]').forEach(function (b) {
279 b.addEventListener('click', function (ev) {
280 ev.preventDefault();
281 if (mode !== 'rendered') return;
282 rendered.focus();
283 document.execCommand(b.getAttribute('data-cmd'), false, null);
284 syncHidden();
285 });
286 });
287
288 // Block-level (H1/H2/H3/P/blockquote)
289 root.querySelectorAll('.wbtn[data-block]').forEach(function (b) {
290 b.addEventListener('click', function (ev) {
291 ev.preventDefault();
292 if (mode !== 'rendered') return;
293 rendered.focus();
294 document.execCommand('formatBlock', false, b.getAttribute('data-block'));
295 syncHidden();
296 });
297 });
298
299 // Action buttons (custom)
300 root.querySelectorAll('.wbtn[data-action]').forEach(function (b) {
301 b.addEventListener('click', function (ev) {
302 ev.preventDefault();
303 if (mode !== 'rendered') return;
304 rendered.focus();
305 var action = b.getAttribute('data-action');
306 if (action === 'link') {
307 var url = prompt('URL?', 'https://');
308 if (url) document.execCommand('createLink', false, url);
309 } else if (action === 'code') {
310 var sel = window.getSelection();
311 if (sel && sel.rangeCount && !sel.isCollapsed) {
312 var range = sel.getRangeAt(0);
313 var code = document.createElement('code');
314 try { range.surroundContents(code); } catch (e) {
315 // fallback for cross-element selections
316 code.appendChild(range.extractContents());
317 range.insertNode(code);
318 }
319 }
320 }
321 syncHidden();
322 });
323 });
324
325 // Insert variable from dropdown
326 if (varSel) {
327 varSel.addEventListener('change', function () {
328 var v = varSel.value;
329 if (!v) return;
330 if (mode === 'rendered') {
331 rendered.focus();
332 document.execCommand('insertText', false, v);
333 } else {
334 var ta = source;
335 var start = ta.selectionStart, end = ta.selectionEnd;
336 ta.value = ta.value.slice(0, start) + v + ta.value.slice(end);
337 ta.selectionStart = ta.selectionEnd = start + v.length;
338 ta.focus();
339 }
340 varSel.value = '';
341 syncHidden();
342 });
343 }
344
345 // Toggle Source / Rendered
346 modeBtn.addEventListener('click', function (ev) {
347 ev.preventDefault();
348 if (mode === 'rendered') {
349 // -> source
350 source.value = rendered.innerHTML;
351 rendered.style.display = 'none';
352 source.style.display = 'block';
353 mode = 'source';
354 modeBtn.innerHTML = 'Rendered &#8644;';
355 source.focus();
356 } else {
357 // -> rendered
358 rendered.innerHTML = source.value;
359 source.style.display = 'none';
360 rendered.style.display = 'block';
361 mode = 'rendered';
362 modeBtn.innerHTML = 'Source &#8644;';
363 rendered.focus();
364 }
365 syncHidden();
366 });
367
368 // Keep hidden field current as the user types
369 rendered.addEventListener('input', syncHidden);
370 source.addEventListener('input', syncHidden);
371
372 // On form submit, force-sync once more so the latest content posts
373 var form = root.closest('form');
374 if (form) form.addEventListener('submit', syncHidden);
375
376 // ----- Preview overlay ---------------------------------------
377 // Sample values shown in the right-side panel + used when the
378 // user clicks Preview. Single source of truth so the panel and
379 // the rendered preview always agree.
380 var SAMPLES = {
381 display_name : 'Shawn (Platform Owner)',
382 first_name : 'Shawn',
383 company_name : "Shawn's Workspace",
384 trial_ends_at : '2026-07-04 09:00:00',
385 days_left : '7',
386 billing_url : 'https://affiliate.3dshawn.com/billing.cgi',
387 dashboard_url : 'https://affiliate.3dshawn.com/dashboard.cgi',
388 unsubscribe_url: 'https://affiliate.3dshawn.com/unsubscribe.cgi?t=SAMPLE',
389 inviter_name : 'Shawn',
390 inviter_email : 'shawn\@example.com',
391 accept_url : 'https://affiliate.3dshawn.com/accept_invite.cgi?token=SAMPLE',
392 reset_url : 'https://affiliate.3dshawn.com/reset_password.cgi?t=SAMPLE'
393 };
394 function fillVars(html) {
395 return (html || '').replace(/\{\{\s*([a-z_][a-z0-9_]*)\s*\}\}/gi, function (_, k) {
396 return (k in SAMPLES) ? SAMPLES[k] : '{{' + k + '}}';
397 });
398 }
399 var backdrop = document.getElementById('wprev-backdrop');
400 var prevBody = document.getElementById('wprev-body');
401 var prevSubj = document.getElementById('wprev-subject');
402 var prevBtn = document.getElementById('wyse-preview');
403 var closeBtn = document.getElementById('wprev-close');
404 function openPreview() {
405 syncHidden();
406 prevBody.innerHTML = fillVars(source.value);
407 var subjInput = document.querySelector('input[name=subject]');
408 prevSubj.textContent = fillVars(subjInput ? subjInput.value : '');
409 backdrop.classList.add('is-open');
410 document.body.style.overflow = 'hidden';
411 }
412 function closePreview() {
413 backdrop.classList.remove('is-open');
414 document.body.style.overflow = '';
415 }
416 if (prevBtn) prevBtn.addEventListener('click', function (ev) { ev.preventDefault(); openPreview(); });
417 if (closeBtn) closeBtn.addEventListener('click', closePreview);
418 backdrop.addEventListener('click', function (ev) {
419 if (ev.target === backdrop) closePreview();
420 });
421 document.addEventListener('keydown', function (ev) {
422 if (ev.key === 'Escape' && backdrop.classList.contains('is-open')) closePreview();
423 });
424 })();
425 </script>
426 <label style="display:flex;align-items:center;gap:8px;font-size:13px;color:#cbd5e1;margin-bottom:18px">
427 <input type="checkbox" name="is_active" value="1" $checked> Active (template can be sent)
428 </label>
429 <button type="submit" class="btn btn-primary btn-lg">Save changes</button>
430 </div>
431 <aside style="background:rgba(20,20,30,.4);padding:16px 18px;border:1px solid rgba(255,255,255,.08);border-radius:12px;height:fit-content">
432 <h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin:0 0 6px">Variables &amp; sample values</h3>
433 <p style="font-size:11px;color:#94a3b8;margin:0 0 12px;line-height:1.5">Drop \{\{name\}\} into the body or subject; at send time it's swapped for the real value. The samples below are what the <strong>Preview</strong> button uses.</p>
434 <table style="font-size:11.5px;line-height:1.5;width:100%;border-collapse:collapse">
435 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{display_name\}\}</td><td style="padding:3px 0;color:#cbd5e1">Shawn (Platform Owner)</td></tr>
436 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{first_name\}\}</td><td style="padding:3px 0;color:#cbd5e1">Shawn</td></tr>
437 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{company_name\}\}</td><td style="padding:3px 0;color:#cbd5e1">Shawn's Workspace</td></tr>
438 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{trial_ends_at\}\}</td><td style="padding:3px 0;color:#cbd5e1">2026-07-04 09:00:00</td></tr>
439 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{days_left\}\}</td><td style="padding:3px 0;color:#cbd5e1">7</td></tr>
440 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{billing_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../billing.cgi</td></tr>
441 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{dashboard_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../dashboard.cgi</td></tr>
442 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{unsubscribe_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../unsubscribe.cgi?t=...</td></tr>
443 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{inviter_name\}\}</td><td style="padding:3px 0;color:#cbd5e1">Shawn</td></tr>
444 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{inviter_email\}\}</td><td style="padding:3px 0;color:#cbd5e1">shawn\@example.com</td></tr>
445 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{accept_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../accept_invite.cgi</td></tr>
446 <tr><td style="padding:3px 6px 3px 0;color:#c4b5fd;font-family:ui-monospace,Consolas,Menlo,monospace;white-space:nowrap">\{\{reset_url\}\}</td><td style="padding:3px 0;color:#94a3b8;font-size:10.5px">.../reset_password.cgi</td></tr>
447 </table>
448 <hr style="border:0;border-top:1px solid rgba(255,255,255,.08);margin:18px 0">
449 <h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin:0 0 12px">Send test</h3>
450 <p style="font-size:12px;color:#94a3b8;margin:0 0 10px">Renders with sample values + sends to:</p>
451 </aside>
452 </form>
453
454 <form method="POST" action="/admin_emails.cgi" style="margin-top:24px;padding:16px;border:1px solid rgba(91,33,182,.3);background:rgba(91,33,182,.05);border-radius:10px">
455 <input type="hidden" name="act" value="send_test">
456 <input type="hidden" name="slug" value="$t->{slug}">
457 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Send test to</label>
458 <div style="display:flex;gap:8px">
459 <input class="input" type="email" name="test_to" value="$u->{email}" style="flex:1">
460 <button type="submit" class="btn btn-secondary">Send test &rarr;</button>
461 </div>
462 </form>
463</div>~;
464 } else {
465 $body = qq~<div class="page-pad"><p style="color:#fca5a5">Template not found.</p></div>~;
466 }
467} else {
468 my $rows = '';
469 my %cat_color = (
470 transactional => '#a78bfa',
471 lifecycle => '#5aa9ff',
472 product => '#34d399',
473 marketing => '#fbbf24',
474 );
475 foreach my $t (@templates) {
476 my $sys = $t->{is_system} ? '<span style="font-size:10px;padding:1px 6px;border-radius:3px;background:rgba(139,92,246,.18);color:#c4b5fd">SYS</span>' : '';
477 my $active = $t->{is_active} ? '<span style="color:#86efac">active</span>' : '<span style="color:#94a3b8">inactive</span>';
478 my $col = $cat_color{$t->{category}} || '#94a3b8';
479 my $sn = _h($t->{name});
480 my $ss = _h($t->{subject});
481 $rows .= qq~
482 <tr style="border-top:1px solid rgba(255,255,255,.05);cursor:pointer" onclick="location.href='/admin_emails.cgi?edit=$t->{slug}'">
483 <td style="padding:10px 12px"><div style="font-weight:600;color:#e2e8f0">$sn $sys</div><div style="font-size:11px;color:#94a3b8;font-family:ui-monospace,Consolas,Menlo,monospace">$t->{slug}</div></td>
484 <td style="padding:10px 12px"><span style="font-size:11px;color:$col;text-transform:uppercase;letter-spacing:.05em;font-weight:600">$t->{category}</span></td>
485 <td style="padding:10px 12px;font-size:13px;color:#cbd5e1">$ss</td>
486 <td style="padding:10px 12px;font-size:12px">$t->{sched_count} sched</td>
487 <td style="padding:10px 12px;font-size:12px">$active</td>
488 </tr>~;
489 }
490 my $sends_html = '';
491 foreach my $s (@recent_sends) {
492 my $color = $s->{status} eq 'sent' ? '#86efac'
493 : $s->{status} eq 'failed' ? '#fca5a5'
494 : $s->{status} =~ /^suppressed/ ? '#fbbf24'
495 : '#94a3b8';
496 my $to = _h($s->{to_email});
497 my $sj = _h($s->{subject});
498 my $rsn = _h($s->{failure_reason} || '');
499 $sends_html .= qq~
500 <tr style="border-top:1px solid rgba(255,255,255,.05)">
501 <td style="padding:6px 10px;font-size:11px;color:#94a3b8"><span class="ts" data-ts="$s->{created_at_epoch}" data-fmt="datetime">$s->{created_at}</span></td>
502 <td style="padding:6px 10px;font-size:12px;color:#cbd5e1;font-family:ui-monospace,Consolas,Menlo,monospace">$s->{template_slug}</td>
503 <td style="padding:6px 10px;font-size:12px;color:#cbd5e1">$to</td>
504 <td style="padding:6px 10px;font-size:11px;color:$color;text-transform:uppercase;letter-spacing:.05em">$s->{status}</td>
505 <td style="padding:6px 10px;font-size:11px;color:#94a3b8">$rsn</td>
506 </tr>~;
507 }
508 my $tabs = _email_setup_tabs('templates');
509 $body = qq~
510<div class="page-pad">
511 <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email Setup</h1>
512 <p style="color:#94a3b8;margin:0 0 18px;font-size:14px">Templates, schedules, and spam-protection controls.</p>
513 $tabs
514 $flash_html
515 <h2 style="font-size:18px;color:#fff;margin:0 0 6px">Templates</h2>
516 <p style="color:#94a3b8;margin:0 0 14px;font-size:13px">Edit subject + body for every email AffSoft sends. Click a row to edit.</p>
517
518 <div style="border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:rgba(20,20,30,.4);margin-bottom:32px">
519 <table style="width:100%;border-collapse:collapse">
520 <thead><tr>
521 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Name</th>
522 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Category</th>
523 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Subject</th>
524 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Schedules</th>
525 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Status</th>
526 </tr></thead>
527 <tbody>$rows</tbody>
528 </table>
529 </div>
530
531 <h2 style="font-size:18px;color:#fff;margin:0 0 14px">Recent sends</h2>
532 <div style="border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:rgba(20,20,30,.4)">
533 <table style="width:100%;border-collapse:collapse">
534 <thead><tr>
535 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">When</th>
536 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">Template</th>
537 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">To</th>
538 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">Status</th>
539 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">Detail</th>
540 </tr></thead>
541 <tbody>$sends_html</tbody>
542 </table>
543 </div>
544</div>~;
545}
546
547$wrap->render({ userinfo => $u, page_key => 'email_setup', title => 'Email Setup', body => $body });
548
549sub _h { my $s = shift // ''; $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g; return $s; }
550
551sub _email_setup_tabs {
552 my ($active) = @_;
553 my %t = (templates => 'Templates', schedules => 'Schedules', controls => 'Controls');
554 my %h = (templates => '/admin_emails.cgi', schedules => '/admin_email_schedules.cgi', controls => '/admin_email_controls.cgi');
555 my $html = '<div style="display:flex;gap:0;border-bottom:1px solid rgba(255,255,255,.08);margin:0 0 22px">';
556 foreach my $k (qw(templates schedules controls)) {
557 my $is_active = ($k eq $active);
558 my $color = $is_active ? '#5aa9ff' : '#94a3b8';
559 my $border = $is_active ? '#5aa9ff' : 'transparent';
560 my $weight = $is_active ? '600' : '500';
561 $html .= qq~<a href="$h{$k}" style="padding:10px 20px;color:$color;text-decoration:none;font-size:13px;border-bottom:2px solid $border;font-weight:$weight;letter-spacing:.02em">$t{$k}</a>~;
562 }
563 $html .= '</div>';
564 return $html;
565}