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

O Operator
Diff

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

added on local at 2026-07-11 18:31:41

Added
+1083
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 688eedb62052
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# ContactForge -- /admin_emails.cgi
4#
5# Email Setup screen. Three tabs (all super-admin only), consolidated
6# behind SCRIPT_NAME dispatch:
7#
8# /admin_emails.cgi -- Templates (list + edit + send-test)
9# /admin_email_schedules.cgi -- Schedules (when each template fires)
10# /admin_email_controls.cgi -- Controls (spam / rate / bounce caps)
11#
12# The two secondary .cgi files are 3-line wrappers that `do` this file.
13#
14# admin_emails.cgi (templates entry):
15# GET list templates (or ?edit=<slug> to edit one)
16# POST act=save update subject/body
17# POST act=send_test send a rendered copy to the admin doing the test
18#
19# admin_email_schedules.cgi:
20# GET list all schedules with inline-editable rows
21# POST act=save update one schedule
22# POST act=add add a new schedule
23# POST act=delete delete a schedule
24#
25# admin_email_controls.cgi:
26# GET show current values
27# POST act=save_controls update platform_settings
28#======================================================================
29use strict;
30use warnings;
31
32use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com';
33use CGI;
34use MODS::DBConnect;
35use MODS::ContactForge::Wrapper;
36use MODS::ContactForge::Config;
37use MODS::Login;
38use MODS::ContactForge::Mailer;
39
40my $q = CGI->new;
41my $form = $q->Vars;
42my $wrap = MODS::ContactForge::Wrapper->new;
43my $db = MODS::DBConnect->new;
44my $cfg = MODS::ContactForge::Config->new;
45my $DB = $cfg->settings('database_name');
46
47my $auth = MODS::Login->new;
48my $u = $auth->login_verify;
49unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
50require MODS::ContactForge::Permissions;
51MODS::ContactForge::Permissions->new->require_super_admin($u);
52my $uid = $u->{user_id} + 0;
53
54my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_emails';
55
56if ($entry eq 'admin_email_controls') { _emx_handle_controls(); exit; }
57if ($entry eq 'admin_email_schedules') { _emx_handle_schedules(); exit; }
58
59# ---- default entry: admin_emails.cgi (Templates tab) ----------------
60my $dbh = $db->db_connect;
61my $act = $form->{act} || '';
62my $flash = '';
63
64# ---- POST handlers --------------------------------------------------
65if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
66 if ($act eq 'save') {
67 my $tid = ($form->{template_id} || 0) + 0;
68 my $name = substr($form->{name} || '', 0, 160); $name =~ s/'/''/g;
69 my $subj = substr($form->{subject} || '', 0, 255); $subj =~ s/'/''/g;
70 my $body = substr($form->{body_html} || '', 0, 65000); $body =~ s/'/''/g;
71 my $cat = $form->{category} || 'lifecycle';
72 $cat = 'lifecycle' unless $cat =~ /^(transactional|lifecycle|product|marketing)$/;
73 my $active = $form->{is_active} ? 1 : 0;
74 $db->db_readwrite($dbh, qq~
75 UPDATE ${DB}.email_templates
76 SET name='$name', subject='$subj', body_html='$body',
77 category='$cat', is_active='$active',
78 updated_by_user_id='$uid'
79 WHERE id='$tid'
80 ~, $0, __LINE__);
81 $flash = 'Saved.';
82 }
83 elsif ($act eq 'send_test') {
84 my $slug = $form->{slug} || '';
85 $slug =~ s/[^a-z0-9_-]//g;
86 my $to = $form->{test_to} || $u->{email} || '';
87 my $mailer = MODS::ContactForge::Mailer->new;
88 my %sample = (
89 display_name => $u->{display_name} || 'Test User',
90 first_name => (split /\s+/, ($u->{display_name} || 'Test'))[0],
91 company_name => $u->{company_name} || 'Test Workspace',
92 trial_ends_at => '2026-07-04 09:00:00',
93 days_left => 7,
94 inviter_name => 'You',
95 inviter_email => $u->{email} || '',
96 accept_url => 'https://crm.3dshawn.com/accept_invite.cgi?token=SAMPLE',
97 reset_url => 'https://crm.3dshawn.com/reset_password.cgi?t=SAMPLE',
98 );
99 my $ok = $mailer->send_template(
100 slug => $slug,
101 user_id => $uid,
102 to => $to,
103 vars => \%sample,
104 );
105 $flash = $ok ? "Test sent to $to." : "Test failed -- check _mail.log and email_sends_log.";
106 }
107}
108
109# ---- GET (list or edit) ---------------------------------------------
110my $edit_slug = $form->{edit} || '';
111$edit_slug =~ s/[^a-z0-9_-]//g;
112
113my @templates = $db->db_readwrite_multiple($dbh, qq~
114 SELECT t.id, t.slug, t.category, t.name, t.subject, t.body_html,
115 t.is_system, t.is_active, t.updated_at,
116 (SELECT COUNT(*) FROM ${DB}.email_schedules WHERE template_id=t.id AND is_active=1) AS sched_count
117 FROM ${DB}.email_templates t
118 ORDER BY t.category, t.slug
119~, $0, __LINE__);
120
121# Last 20 sends for the activity panel
122my @recent_sends = $db->db_readwrite_multiple($dbh, qq~
123 SELECT id, user_id, template_slug, to_email, subject, status, failure_reason, created_at,
124 UNIX_TIMESTAMP(created_at) AS created_epoch
125 FROM ${DB}.email_sends_log ORDER BY id DESC LIMIT 20
126~, $0, __LINE__);
127
128
129$db->db_disconnect($dbh);
130
131print "Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n";
132
133my $body;
134my $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>~ : '';
135
136if ($edit_slug) {
137 my ($t) = grep { $_->{slug} eq $edit_slug } @templates;
138 if ($t) {
139 my $name = _emx_h($t->{name});
140 my $subj = _emx_h($t->{subject});
141 my $body_h = _emx_h($t->{body_html});
142 my $cat = $t->{category};
143 my $checked = $t->{is_active} ? 'checked' : '';
144 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>' : '';
145 my @opts;
146 foreach my $k (qw(transactional lifecycle product marketing)) {
147 my $sel = ($k eq $cat) ? ' selected' : '';
148 push @opts, qq~<option value="$k"$sel>$k</option>~;
149 }
150 my $opt_html = join('', @opts);
151 my $tabs = _emx_email_setup_tabs('templates');
152 $body = qq~
153<div class="page-pad">
154 <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email Setup</h1>
155 <p style="color:#94a3b8;margin:0 0 18px;font-size:14px">Templates, schedules, and spam-protection controls.</p>
156 $tabs
157 <a href="/admin_emails.cgi" style="color:#94a3b8;font-size:13px;text-decoration:none">&larr; All templates</a>
158 <h2 style="margin:8px 0 6px;font-size:22px;color:#fff">Edit: $name $is_sys</h2>
159 <p style="color:#94a3b8;margin:0 0 24px;font-size:14px"><code style="color:#c4b5fd">$t->{slug}</code> &middot; category <strong>$cat</strong></p>
160 $flash_html
161 <form method="POST" action="/admin_emails.cgi" style="display:grid;grid-template-columns:1fr 280px;gap:24px">
162 <div>
163 <input type="hidden" name="act" value="save">
164 <input type="hidden" name="template_id" value="$t->{id}">
165 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Name</label>
166 <input class="input" type="text" name="name" value="$name" maxlength="160" style="width:100%;margin-bottom:14px">
167 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Category</label>
168 <select class="select" name="category" style="width:100%;margin-bottom:14px">$opt_html</select>
169 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Subject</label>
170 <input class="input" type="text" name="subject" value="$subj" maxlength="255" style="width:100%;margin-bottom:14px">
171 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Body</label>
172 <div class="wyse" id="wyse" style="margin-bottom:14px">
173 <div class="wyse-toolbar">
174 <button type="button" class="wbtn" data-cmd="bold" title="Bold (Ctrl+B)"><b>B</b></button>
175 <button type="button" class="wbtn" data-cmd="italic" title="Italic (Ctrl+I)"><i>I</i></button>
176 <button type="button" class="wbtn" data-cmd="underline" title="Underline"><u>U</u></button>
177 <button type="button" class="wbtn" data-cmd="strikeThrough" title="Strikethrough"><s>S</s></button>
178 <span class="wsep"></span>
179 <button type="button" class="wbtn wbtn-text" data-block="h1" title="Heading 1">H1</button>
180 <button type="button" class="wbtn wbtn-text" data-block="h2" title="Heading 2">H2</button>
181 <button type="button" class="wbtn wbtn-text" data-block="h3" title="Heading 3">H3</button>
182 <button type="button" class="wbtn wbtn-text" data-block="p" title="Paragraph">P</button>
183 <span class="wsep"></span>
184 <button type="button" class="wbtn" data-cmd="insertUnorderedList" title="Bulleted list">&bull;</button>
185 <button type="button" class="wbtn" data-cmd="insertOrderedList" title="Numbered list">1.</button>
186 <button type="button" class="wbtn wbtn-text" data-block="blockquote" title="Block quote">&ldquo;</button>
187 <button type="button" class="wbtn wbtn-text" data-action="code" title="Inline code">&lt;/&gt;</button>
188 <span class="wsep"></span>
189 <button type="button" class="wbtn wbtn-text" data-action="link" title="Insert link">Link</button>
190 <button type="button" class="wbtn" data-cmd="unlink" title="Remove link">&#128279;</button>
191 <button type="button" class="wbtn wbtn-text" data-cmd="insertHorizontalRule" title="Insert horizontal rule">&mdash;</button>
192 <span class="wsep"></span>
193 <select class="wsel" id="wyse-var" title="Insert a template variable at the cursor">
194 <option value="">+ Variable</option>
195 <option value="{{display_name}}">display_name</option>
196 <option value="{{first_name}}">first_name</option>
197 <option value="{{company_name}}">company_name</option>
198 <option value="{{trial_ends_at}}">trial_ends_at</option>
199 <option value="{{days_left}}">days_left</option>
200 <option value="{{billing_url}}">billing_url</option>
201 <option value="{{dashboard_url}}">dashboard_url</option>
202 <option value="{{unsubscribe_url}}">unsubscribe_url</option>
203 <option value="{{inviter_name}}">inviter_name</option>
204 <option value="{{inviter_email}}">inviter_email</option>
205 <option value="{{accept_url}}">accept_url</option>
206 <option value="{{reset_url}}">reset_url</option>
207 </select>
208 <span class="wsep" style="margin-left:auto"></span>
209 <button type="button" class="wbtn wbtn-text wbtn-preview" id="wyse-preview" title="Preview the email with sample values substituted">Preview &#9654;</button>
210 <button type="button" class="wbtn wbtn-text wbtn-mode" id="wyse-mode" title="Toggle between rendered HTML and raw source">Source &#8644;</button>
211 </div>
212 <div class="wyse-rendered" id="wyse-rendered" contenteditable="true" spellcheck="true"></div>
213 <textarea class="wyse-source" id="wyse-source" style="display:none">$body_h</textarea>
214 </div>
215 <input type="hidden" name="body_html" id="wyse-hidden">
216 <div class="wprev-backdrop" id="wprev-backdrop" role="dialog" aria-modal="true">
217 <div class="wprev-modal">
218 <div class="wprev-head">
219 <div class="wprev-title">Preview &middot; sample values substituted</div>
220 <button type="button" class="wprev-close" id="wprev-close">Close</button>
221 </div>
222 <div class="wprev-meta">
223 <div class="wprev-from">From: ContactForge &lt;noreply\@crm.3dshawn.com&gt; &middot; To: shawn\@example.com</div>
224 <div class="wprev-subject" id="wprev-subject"></div>
225 </div>
226 <div class="wprev-body" id="wprev-body"></div>
227 </div>
228 </div>
229 <style>
230 .wyse { border:1px solid rgba(255,255,255,.10); border-radius:10px; overflow:hidden; background:#0f1420; }
231 .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; }
232 .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; }
233 .wyse .wbtn:hover { background:rgba(255,255,255,.06); color:#fff; }
234 .wyse .wbtn.is-active { background:rgba(90,169,255,.20); color:#bfdbff; border-color:rgba(90,169,255,.35); }
235 .wyse .wbtn-text { font-size:12px; font-weight:600; }
236 .wyse .wsep { width:1px; height:18px; background:rgba(255,255,255,.10); margin:0 6px; }
237 .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; }
238 .wyse .wbtn-mode { background:rgba(139,92,246,.15); color:#c4b5fd; border-color:rgba(139,92,246,.30); }
239 .wyse .wbtn-mode:hover { background:rgba(139,92,246,.25); }
240 .wyse .wbtn-preview { background:rgba(34,197,94,.14); color:#86efac; border-color:rgba(34,197,94,.30); }
241 .wyse .wbtn-preview:hover { background:rgba(34,197,94,.24); }
242 .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; }
243 .wprev-backdrop.is-open { display:flex; }
244 .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); }
245 .wprev-head { display:flex; align-items:center; padding:14px 20px; border-bottom:1px solid rgba(255,255,255,.10); background:#171b2a; gap:14px; }
246 .wprev-head .wprev-title { font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:#94a3b8; font-weight:600; }
247 .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; }
248 .wprev-head .wprev-close:hover { background:rgba(255,255,255,.06); }
249 .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); }
250 .wprev-meta .wprev-from { color:#cbd5e1; font-weight:500; }
251 .wprev-meta .wprev-subject { color:#f1f5f9; font-weight:700; font-size:15px; margin-top:6px; }
252 .wprev-body { padding:24px 28px; line-height:1.65; font-size:14px; max-height:60vh; overflow:auto; }
253 .wprev-body p { margin:.6em 0; }
254 .wprev-body h1 { font-size:22px; margin:.4em 0 .3em; color:#f1f5f9; }
255 .wprev-body h2 { font-size:18px; margin:.5em 0 .3em; color:#f1f5f9; }
256 .wprev-body h3 { font-size:16px; margin:.5em 0 .3em; color:#f1f5f9; }
257 .wprev-body ul, .wprev-body ol { padding-left:1.6em; margin:.5em 0; }
258 .wprev-body a { color:#5aa9ff; text-decoration:underline; }
259 .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; }
260 .wprev-body blockquote { border-left:3px solid rgba(255,255,255,.20); margin:.8em 0; padding:.2em 1em; color:#cbd5e1; }
261 .wprev-body hr { border:0; border-top:1px solid rgba(255,255,255,.15); margin:1em 0; }
262 .wyse-rendered { min-height:380px; padding:16px 20px; color:#e2e8f0; font-size:14px; line-height:1.6; outline:none; background:#0b0f1a; }
263 .wyse-rendered:focus { background:#0b0f1a; }
264 .wyse-rendered h1 { font-size:22px; margin:.4em 0 .3em; color:#f1f5f9; }
265 .wyse-rendered h2 { font-size:18px; margin:.5em 0 .3em; color:#f1f5f9; }
266 .wyse-rendered h3 { font-size:16px; margin:.5em 0 .3em; color:#f1f5f9; }
267 .wyse-rendered p { margin:.5em 0; }
268 .wyse-rendered ul, .wyse-rendered ol { padding-left:1.6em; margin:.5em 0; }
269 .wyse-rendered a { color:#5aa9ff; text-decoration:underline; }
270 .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; }
271 .wyse-rendered blockquote { border-left:3px solid rgba(255,255,255,.20); margin:.8em 0; padding:.2em 1em; color:#cbd5e1; }
272 .wyse-rendered hr { border:0; border-top:1px solid rgba(255,255,255,.15); margin:1em 0; }
273 .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; }
274 </style>
275 <script>
276 (function () {
277 var root = document.getElementById('wyse');
278 var rendered= document.getElementById('wyse-rendered');
279 var source = document.getElementById('wyse-source');
280 var hidden = document.getElementById('wyse-hidden');
281 var modeBtn = document.getElementById('wyse-mode');
282 var varSel = document.getElementById('wyse-var');
283 if (!root || !rendered || !source) return;
284
285 var mode = 'rendered';
286
287 // Hydrate the rendered pane from the source HTML on load.
288 rendered.innerHTML = source.value;
289 hidden.value = source.value;
290
291 function syncHidden() {
292 if (mode === 'rendered') {
293 source.value = rendered.innerHTML;
294 } else {
295 rendered.innerHTML = source.value;
296 }
297 hidden.value = source.value;
298 }
299
300 // Toolbar formatting buttons via execCommand (legacy but ubiquitous).
301 root.querySelectorAll('.wbtn[data-cmd]').forEach(function (b) {
302 b.addEventListener('click', function (ev) {
303 ev.preventDefault();
304 if (mode !== 'rendered') return;
305 rendered.focus();
306 document.execCommand(b.getAttribute('data-cmd'), false, null);
307 syncHidden();
308 });
309 });
310
311 // Block-level (H1/H2/H3/P/blockquote)
312 root.querySelectorAll('.wbtn[data-block]').forEach(function (b) {
313 b.addEventListener('click', function (ev) {
314 ev.preventDefault();
315 if (mode !== 'rendered') return;
316 rendered.focus();
317 document.execCommand('formatBlock', false, b.getAttribute('data-block'));
318 syncHidden();
319 });
320 });
321
322 // Action buttons (custom)
323 root.querySelectorAll('.wbtn[data-action]').forEach(function (b) {
324 b.addEventListener('click', function (ev) {
325 ev.preventDefault();
326 if (mode !== 'rendered') return;
327 rendered.focus();
328 var action = b.getAttribute('data-action');
329 if (action === 'link') {
330 var url = prompt('URL?', 'https://');
331 if (url) document.execCommand('createLink', false, url);
332 } else if (action === 'code') {
333 var sel = window.getSelection();
334 if (sel && sel.rangeCount && !sel.isCollapsed) {
335 var range = sel.getRangeAt(0);
336 var code = document.createElement('code');
337 try { range.surroundContents(code); } catch (e) {
338 // fallback for cross-element selections
339 code.appendChild(range.extractContents());
340 range.insertNode(code);
341 }
342 }
343 }
344 syncHidden();
345 });
346 });
347
348 // Insert variable from dropdown
349 if (varSel) {
350 varSel.addEventListener('change', function () {
351 var v = varSel.value;
352 if (!v) return;
353 if (mode === 'rendered') {
354 rendered.focus();
355 document.execCommand('insertText', false, v);
356 } else {
357 var ta = source;
358 var start = ta.selectionStart, end = ta.selectionEnd;
359 ta.value = ta.value.slice(0, start) + v + ta.value.slice(end);
360 ta.selectionStart = ta.selectionEnd = start + v.length;
361 ta.focus();
362 }
363 varSel.value = '';
364 syncHidden();
365 });
366 }
367
368 // Toggle Source / Rendered
369 modeBtn.addEventListener('click', function (ev) {
370 ev.preventDefault();
371 if (mode === 'rendered') {
372 // -> source
373 source.value = rendered.innerHTML;
374 rendered.style.display = 'none';
375 source.style.display = 'block';
376 mode = 'source';
377 modeBtn.innerHTML = 'Rendered &#8644;';
378 source.focus();
379 } else {
380 // -> rendered
381 rendered.innerHTML = source.value;
382 source.style.display = 'none';
383 rendered.style.display = 'block';
384 mode = 'rendered';
385 modeBtn.innerHTML = 'Source &#8644;';
386 rendered.focus();
387 }
388 syncHidden();
389 });
390
391 // Keep hidden field current as the user types
392 rendered.addEventListener('input', syncHidden);
393 source.addEventListener('input', syncHidden);
394
395 // On form submit, force-sync once more so the latest content posts
396 var form = root.closest('form');
397 if (form) form.addEventListener('submit', syncHidden);
398
399 // ----- Preview overlay ---------------------------------------
400 // Sample values shown in the right-side panel + used when the
401 // user clicks Preview. Single source of truth so the panel and
402 // the rendered preview always agree.
403 var SAMPLES = {
404 display_name : 'Shawn (Platform Owner)',
405 first_name : 'Shawn',
406 company_name : "Shawn's Workspace",
407 trial_ends_at : '2026-07-04 09:00:00',
408 days_left : '7',
409 billing_url : 'https://crm.3dshawn.com/billing.cgi',
410 dashboard_url : 'https://crm.3dshawn.com/dashboard.cgi',
411 unsubscribe_url: 'https://crm.3dshawn.com/unsubscribe.cgi?t=SAMPLE',
412 inviter_name : 'Shawn',
413 inviter_email : 'shawn\@example.com',
414 accept_url : 'https://crm.3dshawn.com/accept_invite.cgi?token=SAMPLE',
415 reset_url : 'https://crm.3dshawn.com/reset_password.cgi?t=SAMPLE'
416 };
417 function fillVars(html) {
418 return (html || '').replace(/\{\{\s*([a-z_][a-z0-9_]*)\s*\}\}/gi, function (_, k) {
419 return (k in SAMPLES) ? SAMPLES[k] : '{{' + k + '}}';
420 });
421 }
422 var backdrop = document.getElementById('wprev-backdrop');
423 var prevBody = document.getElementById('wprev-body');
424 var prevSubj = document.getElementById('wprev-subject');
425 var prevBtn = document.getElementById('wyse-preview');
426 var closeBtn = document.getElementById('wprev-close');
427 function openPreview() {
428 syncHidden();
429 prevBody.innerHTML = fillVars(source.value);
430 var subjInput = document.querySelector('input[name=subject]');
431 prevSubj.textContent = fillVars(subjInput ? subjInput.value : '');
432 backdrop.classList.add('is-open');
433 document.body.style.overflow = 'hidden';
434 }
435 function closePreview() {
436 backdrop.classList.remove('is-open');
437 document.body.style.overflow = '';
438 }
439 if (prevBtn) prevBtn.addEventListener('click', function (ev) { ev.preventDefault(); openPreview(); });
440 if (closeBtn) closeBtn.addEventListener('click', closePreview);
441 backdrop.addEventListener('click', function (ev) {
442 if (ev.target === backdrop) closePreview();
443 });
444 document.addEventListener('keydown', function (ev) {
445 if (ev.key === 'Escape' && backdrop.classList.contains('is-open')) closePreview();
446 });
447 })();
448 </script>
449 <label style="display:flex;align-items:center;gap:8px;font-size:13px;color:#cbd5e1;margin-bottom:18px">
450 <input type="checkbox" name="is_active" value="1" $checked> Active (template can be sent)
451 </label>
452 <button type="submit" class="btn btn-primary btn-lg">Save changes</button>
453 </div>
454 <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">
455 <h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin:0 0 6px">Variables &amp; sample values</h3>
456 <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>
457 <table style="font-size:11.5px;line-height:1.5;width:100%;border-collapse:collapse">
458 <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>
459 <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>
460 <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>
461 <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>
462 <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>
463 <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>
464 <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>
465 <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>
466 <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>
467 <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>
468 <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>
469 <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>
470 </table>
471 <hr style="border:0;border-top:1px solid rgba(255,255,255,.08);margin:18px 0">
472 <h3 style="font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin:0 0 12px">Send test</h3>
473 <p style="font-size:12px;color:#94a3b8;margin:0 0 10px">Renders with sample values + sends to:</p>
474 </aside>
475 </form>
476
477 <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">
478 <input type="hidden" name="act" value="send_test">
479 <input type="hidden" name="slug" value="$t->{slug}">
480 <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Send test to</label>
481 <div style="display:flex;gap:8px">
482 <input class="input" type="email" name="test_to" value="$u->{email}" style="flex:1">
483 <button type="submit" class="btn btn-secondary">Send test &rarr;</button>
484 </div>
485 </form>
486</div>~;
487 } else {
488 $body = qq~<div class="page-pad"><p style="color:#fca5a5">Template not found.</p></div>~;
489 }
490} else {
491 my $rows = '';
492 my %cat_color = (
493 transactional => '#a78bfa',
494 lifecycle => '#5aa9ff',
495 product => '#34d399',
496 marketing => '#fbbf24',
497 );
498 foreach my $t (@templates) {
499 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>' : '';
500 my $active = $t->{is_active} ? '<span style="color:#86efac">active</span>' : '<span style="color:#94a3b8">inactive</span>';
501 my $col = $cat_color{$t->{category}} || '#94a3b8';
502 my $sn = _emx_h($t->{name});
503 my $ss = _emx_h($t->{subject});
504 $rows .= qq~
505 <tr style="border-top:1px solid rgba(255,255,255,.05);cursor:pointer" onclick="location.href='/admin_emails.cgi?edit=$t->{slug}'">
506 <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>
507 <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>
508 <td style="padding:10px 12px;font-size:13px;color:#cbd5e1">$ss</td>
509 <td style="padding:10px 12px;font-size:12px">$t->{sched_count} sched</td>
510 <td style="padding:10px 12px;font-size:12px">$active</td>
511 </tr>~;
512 }
513 my $sends_html = '';
514 foreach my $s (@recent_sends) {
515 my $color = $s->{status} eq 'sent' ? '#86efac'
516 : $s->{status} eq 'failed' ? '#fca5a5'
517 : $s->{status} =~ /^suppressed/ ? '#fbbf24'
518 : '#94a3b8';
519 my $to = _emx_h($s->{to_email});
520 my $sj = _emx_h($s->{subject});
521 my $rsn = _emx_h($s->{failure_reason} || '');
522 $sends_html .= qq~
523 <tr style="border-top:1px solid rgba(255,255,255,.05)">
524 <td style="padding:6px 10px;font-size:11px;color:#94a3b8"><span class="ts" data-ts="$s->{created_epoch}" data-fmt="datetime">$s->{created_at}</span></td>
525 <td style="padding:6px 10px;font-size:12px;color:#cbd5e1;font-family:ui-monospace,Consolas,Menlo,monospace">$s->{template_slug}</td>
526 <td style="padding:6px 10px;font-size:12px;color:#cbd5e1">$to</td>
527 <td style="padding:6px 10px;font-size:11px;color:$color;text-transform:uppercase;letter-spacing:.05em">$s->{status}</td>
528 <td style="padding:6px 10px;font-size:11px;color:#94a3b8">$rsn</td>
529 </tr>~;
530 }
531 my $tabs = _emx_email_setup_tabs('templates');
532 $body = qq~
533<div class="page-pad">
534 <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email Setup</h1>
535 <p style="color:#94a3b8;margin:0 0 18px;font-size:14px">Templates, schedules, and spam-protection controls.</p>
536 $tabs
537 $flash_html
538 <h2 style="font-size:18px;color:#fff;margin:0 0 6px">Templates</h2>
539 <p style="color:#94a3b8;margin:0 0 14px;font-size:13px">Edit subject + body for every email ContactForge sends. Click a row to edit.</p>
540
541 <div style="border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:rgba(20,20,30,.4);margin-bottom:32px">
542 <table style="width:100%;border-collapse:collapse">
543 <thead><tr>
544 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Name</th>
545 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Category</th>
546 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Subject</th>
547 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Schedules</th>
548 <th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Status</th>
549 </tr></thead>
550 <tbody>$rows</tbody>
551 </table>
552 </div>
553
554 <h2 style="font-size:18px;color:#fff;margin:0 0 14px">Recent sends</h2>
555 <div style="border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:rgba(20,20,30,.4)">
556 <table style="width:100%;border-collapse:collapse">
557 <thead><tr>
558 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">When</th>
559 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">Template</th>
560 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">To</th>
561 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">Status</th>
562 <th style="padding:6px 10px;text-align:left;font-size:10px;color:#94a3b8;text-transform:uppercase">Detail</th>
563 </tr></thead>
564 <tbody>$sends_html</tbody>
565 </table>
566 </div>
567</div>~;
568}
569
570$wrap->render({ userinfo => $u, page_key => 'email_setup', title => 'Email Setup', body => $body });
571
572exit;
573
574#======================================================================
575# admin_email_controls entry: Controls tab (rate/bounce/abuse caps).
576#======================================================================
577sub _emx_handle_controls {
578 my $dbh = $db->db_connect;
579 my $act = $form->{act} || '';
580 my $flash = '';
581
582 if (($ENV{REQUEST_METHOD} || '') eq 'POST' && $act eq 'save_controls') {
583 my %controls = (
584 email_rate_hourly_cap => { min=>1, max=>10000, default=>20 },
585 email_rate_daily_cap => { min=>1, max=>100000, default=>100 },
586 email_dedupe_window_hours => { min=>0, max=>720, default=>24 },
587 email_account_age_min_hours => { min=>0, max=>720, default=>1 },
588 email_bounce_suspend_pct => { min=>0, max=>100, default=>10 },
589 email_bounce_min_volume => { min=>1, max=>100000, default=>20 },
590 email_abuse_suspend_threshold => { min=>1, max=>100, default=>1 },
591 email_suspend_days => { min=>1, max=>365, default=>7 },
592 );
593 foreach my $k (keys %controls) {
594 my $raw = $form->{$k}; next unless defined $raw;
595 my $n = $raw + 0;
596 my $spec = $controls{$k};
597 $n = $spec->{default} if ($n < $spec->{min} || $n > $spec->{max});
598 my $kq = $k; $kq =~ s/'/''/g;
599 my $vq = $n; $vq =~ s/'/''/g;
600 $db->db_readwrite($dbh, qq~
601 INSERT INTO ${DB}.platform_settings (setting_key, setting_value)
602 VALUES ('$kq', '$vq')
603 ON DUPLICATE KEY UPDATE setting_value='$vq'
604 ~, $0, __LINE__);
605 }
606 $flash = 'Email controls saved.';
607 }
608
609 my @control_rows = $db->db_readwrite_multiple($dbh, qq~
610 SELECT setting_key, setting_value FROM ${DB}.platform_settings
611 WHERE setting_key IN (
612 'email_rate_hourly_cap','email_rate_daily_cap','email_dedupe_window_hours',
613 'email_account_age_min_hours','email_bounce_suspend_pct','email_bounce_min_volume',
614 'email_abuse_suspend_threshold','email_suspend_days')
615 ~, $0, __LINE__);
616 my %controls;
617 foreach my $r (@control_rows) { $controls{$r->{setting_key}} = $r->{setting_value}; }
618 my %control_defaults = (
619 email_rate_hourly_cap => 20, email_rate_daily_cap => 100,
620 email_dedupe_window_hours => 24, email_account_age_min_hours => 1,
621 email_bounce_suspend_pct => 10, email_bounce_min_volume => 20,
622 email_abuse_suspend_threshold => 1, email_suspend_days => 7,
623 );
624 foreach my $k (keys %control_defaults) { $controls{$k} //= $control_defaults{$k}; }
625
626 $db->db_disconnect($dbh);
627
628 print "Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n";
629
630 my $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>~ : '';
631
632 my $tabs = _emx_email_setup_tabs('controls');
633
634 my $body = qq~
635<div class="page-pad">
636 <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email Setup</h1>
637 <p style="color:#94a3b8;margin:0 0 18px;font-size:14px">Templates, schedules, and spam-protection controls.</p>
638 $tabs
639 $flash_html
640 <h2 style="font-size:18px;color:#fff;margin:0 0 6px">Controls <span style="font-size:11px;color:#94a3b8;font-weight:400;letter-spacing:.05em">&middot; SPAM PROTECTION</span></h2>
641 <p style="color:#94a3b8;margin:0 0 14px;font-size:13px">Tune the per-account caps, account-age gate, bounce auto-suspend, and abuse-report threshold. Defaults are conservative &mdash; raise carefully.</p>
642 <form method="POST" action="/admin_email_controls.cgi" style="border:1px solid rgba(255,255,255,.08);border-radius:12px;background:rgba(20,20,30,.4);padding:18px">
643 <input type="hidden" name="act" value="save_controls">
644 <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px">
645 <label style="font-size:12px;color:#cbd5e1">
646 <div style="text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;font-size:10px;margin-bottom:4px">Hourly burst cap</div>
647 <input class="input" type="number" name="email_rate_hourly_cap" value="$controls{email_rate_hourly_cap}" min="1" max="10000" style="width:100%">
648 <div style="color:#94a3b8;font-size:11px;margin-top:4px">Sends/account/hour. Hard cap.</div>
649 </label>
650 <label style="font-size:12px;color:#cbd5e1">
651 <div style="text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;font-size:10px;margin-bottom:4px">Daily cap</div>
652 <input class="input" type="number" name="email_rate_daily_cap" value="$controls{email_rate_daily_cap}" min="1" max="100000" style="width:100%">
653 <div style="color:#94a3b8;font-size:11px;margin-top:4px">Sends/account/24h. Hard cap.</div>
654 </label>
655 <label style="font-size:12px;color:#cbd5e1">
656 <div style="text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;font-size:10px;margin-bottom:4px">Dedupe window (hours)</div>
657 <input class="input" type="number" name="email_dedupe_window_hours" value="$controls{email_dedupe_window_hours}" min="0" max="720" style="width:100%">
658 <div style="color:#94a3b8;font-size:11px;margin-top:4px">Same recipient + same template within N hours = drop.</div>
659 </label>
660 <label style="font-size:12px;color:#cbd5e1">
661 <div style="text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;font-size:10px;margin-bottom:4px">Account-age gate (hours)</div>
662 <input class="input" type="number" name="email_account_age_min_hours" value="$controls{email_account_age_min_hours}" min="0" max="720" style="width:100%">
663 <div style="color:#94a3b8;font-size:11px;margin-top:4px">Min account age before inviter can send (password reset / verify bypass).</div>
664 </label>
665 <label style="font-size:12px;color:#cbd5e1">
666 <div style="text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;font-size:10px;margin-bottom:4px">Bounce suspend %</div>
667 <input class="input" type="number" name="email_bounce_suspend_pct" value="$controls{email_bounce_suspend_pct}" min="0" max="100" style="width:100%">
668 <div style="color:#94a3b8;font-size:11px;margin-top:4px">Auto-suspend inviter at &gt;= this % bounces.</div>
669 </label>
670 <label style="font-size:12px;color:#cbd5e1">
671 <div style="text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;font-size:10px;margin-bottom:4px">Bounce min volume</div>
672 <input class="input" type="number" name="email_bounce_min_volume" value="$controls{email_bounce_min_volume}" min="1" max="100000" style="width:100%">
673 <div style="color:#94a3b8;font-size:11px;margin-top:4px">% only triggers above this send volume (avoids 1/1 = 100%).</div>
674 </label>
675 <label style="font-size:12px;color:#cbd5e1">
676 <div style="text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;font-size:10px;margin-bottom:4px">Abuse-report threshold</div>
677 <input class="input" type="number" name="email_abuse_suspend_threshold" value="$controls{email_abuse_suspend_threshold}" min="1" max="100" style="width:100%">
678 <div style="color:#94a3b8;font-size:11px;margin-top:4px">N reports in 30d = auto-suspend.</div>
679 </label>
680 <label style="font-size:12px;color:#cbd5e1">
681 <div style="text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;font-size:10px;margin-bottom:4px">Suspend duration (days)</div>
682 <input class="input" type="number" name="email_suspend_days" value="$controls{email_suspend_days}" min="1" max="365" style="width:100%">
683 <div style="color:#94a3b8;font-size:11px;margin-top:4px">How long the auto-suspend lasts.</div>
684 </label>
685 </div>
686 <div style="margin-top:18px">
687 <button type="submit" class="btn" style="background:#5aa9ff;color:#0b1220;border:0;border-radius:8px;padding:10px 18px;font-weight:600">Save email controls</button>
688 </div>
689 </form>
690</div>~;
691
692 $wrap->render({ userinfo => $u, page_key => 'email_setup', title => 'Email Setup', body => $body });
693 return;
694}
695
696#======================================================================
697# admin_email_schedules entry: Schedules tab (when each email fires).
698#======================================================================
699sub _emx_handle_schedules {
700 my $dbh = $db->db_connect;
701 my $act = $form->{act} || '';
702 my $flash = '';
703
704 my @EVENTS = qw(trial_end_at signup invoice_paid invoice_failed password_reset);
705 my %EVENT_DESC = (
706 trial_end_at => "Relative to companies.trial_ends_at",
707 signup => "Relative to user signup",
708 invoice_paid => "Relative to a successful invoice payment",
709 invoice_failed => "Relative to a failed invoice charge",
710 password_reset => "When a password reset is requested",
711 );
712
713 if (($ENV{REQUEST_METHOD} || '') eq 'POST') {
714 if ($act eq 'save') {
715 my $sid = ($form->{schedule_id} || 0) + 0;
716 my $off_d = ($form->{offset_days} || 0) + 0;
717 my $off_h = ($form->{offset_hours} || 0) + 0;
718 my $hr = $form->{send_at_local_hour};
719 my $hr_sql = (defined $hr && $hr =~ /^\d+$/ && $hr >= 0 && $hr <= 23) ? "'$hr'" : 'NULL';
720 my $ev = $form->{trigger_event} || 'trial_end_at';
721 $ev = 'trial_end_at' unless grep { $_ eq $ev } @EVENTS;
722 my $active = $form->{is_active} ? 1 : 0;
723 my $notes = substr($form->{notes} || '', 0, 250); $notes =~ s/'/''/g;
724 $db->db_readwrite($dbh, qq~
725 UPDATE ${DB}.email_schedules
726 SET trigger_event='$ev', offset_days='$off_d', offset_hours='$off_h',
727 send_at_local_hour=$hr_sql, is_active='$active', notes='$notes',
728 updated_by_user_id='$uid'
729 WHERE id='$sid'
730 ~, $0, __LINE__);
731 $flash = 'Schedule updated.';
732 }
733 elsif ($act eq 'add') {
734 my $tid = ($form->{template_id} || 0) + 0;
735 my $ev = $form->{trigger_event} || 'trial_end_at';
736 $ev = 'trial_end_at' unless grep { $_ eq $ev } @EVENTS;
737 my $off_d = ($form->{offset_days} || 0) + 0;
738 my $off_h = ($form->{offset_hours} || 0) + 0;
739 if ($tid) {
740 $db->db_readwrite($dbh, qq~
741 INSERT INTO ${DB}.email_schedules
742 (template_id, trigger_event, offset_days, offset_hours, is_active, created_at, updated_by_user_id)
743 VALUES ('$tid', '$ev', '$off_d', '$off_h', 1, NOW(), '$uid')
744 ~, $0, __LINE__);
745 $flash = 'Schedule added.';
746 }
747 }
748 elsif ($act eq 'delete') {
749 my $sid = ($form->{schedule_id} || 0) + 0;
750 if ($sid) {
751 $db->db_readwrite($dbh, qq~DELETE FROM ${DB}.email_schedules WHERE id='$sid'~, $0, __LINE__);
752 $flash = 'Schedule deleted.';
753 }
754 }
755 }
756
757 my @scheds = $db->db_readwrite_multiple($dbh, qq~
758 SELECT s.id, s.template_id, s.trigger_event, s.offset_days, s.offset_hours,
759 s.send_at_local_hour, s.is_active, s.notes, s.updated_at,
760 t.slug, t.name AS template_name, t.category
761 FROM ${DB}.email_schedules s
762 JOIN ${DB}.email_templates t ON t.id = s.template_id
763 ORDER BY s.trigger_event, s.offset_days, t.slug
764 ~, $0, __LINE__);
765
766 my @templates = $db->db_readwrite_multiple($dbh, qq~
767 SELECT id, slug, name, category FROM ${DB}.email_templates ORDER BY category, slug
768 ~, $0, __LINE__);
769
770 $db->db_disconnect($dbh);
771
772 print "Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n";
773
774 my $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>~ : '';
775
776 my $rows = '';
777 foreach my $s (@scheds) {
778 my $hr_val = defined($s->{send_at_local_hour}) ? $s->{send_at_local_hour} : '';
779 my $checked = $s->{is_active} ? 'checked' : '';
780 my $name = _emx_h($s->{template_name});
781 my $notes = _emx_h($s->{notes} || '');
782 my $ev_opts = '';
783 foreach my $e (@EVENTS) {
784 my $sel = ($e eq $s->{trigger_event}) ? ' selected' : '';
785 $ev_opts .= qq~<option value="$e"$sel>$e</option>~;
786 }
787 my $timing = ($s->{offset_days} == 0 && $s->{offset_hours} == 0) ? 'on event'
788 : ($s->{offset_days} < 0 || $s->{offset_hours} < 0) ? "before"
789 : "after";
790 my $timing_color = $timing eq 'before' ? '#5aa9ff'
791 : $timing eq 'after' ? '#fbbf24'
792 : '#86efac';
793 my $timing_label = uc($timing);
794 $rows .= qq~
795 <tr style="border-top:1px solid rgba(255,255,255,.05)">
796 <td style="padding:14px 14px;font-size:13px;color:#e2e8f0;vertical-align:top;width:180px">
797 <div style="font-weight:600">$name</div>
798 <div style="font-size:11px;color:#94a3b8;font-family:ui-monospace,Consolas,Menlo,monospace;margin-top:4px">$s->{slug}</div>
799 </td>
800 <td style="padding:14px 14px;vertical-align:top">
801 <form method="POST" action="/admin_email_schedules.cgi" style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:0;align-items:start">
802 <input type="hidden" name="act" value="save">
803 <input type="hidden" name="schedule_id" value="$s->{id}">
804
805 <div>
806 <label style="display:block;font-size:10px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Trigger event</label>
807 <select name="trigger_event" class="select" style="font-size:13px;padding:6px 8px;width:100%">$ev_opts</select>
808 </div>
809
810 <div>
811 <label style="display:block;font-size:10px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Notes</label>
812 <textarea class="input grow-on-focus" name="notes" rows="1" placeholder="optional note shown only on this admin page" style="font-size:13px;padding:6px 8px;width:100%;height:32px;resize:none;overflow:hidden;transition:height .15s ease;font-family:inherit;line-height:1.4" onfocus="this.style.height='120px';this.style.overflow='auto'" onblur="this.style.height='32px';this.style.overflow='hidden'">$notes</textarea>
813 </div>
814
815 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-start">
816 <div style="border:1px solid rgba(90,169,255,.18);background:rgba(90,169,255,.04);border-radius:8px;padding:8px 12px">
817 <label style="display:block;font-size:10px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Offset <span style="color:$timing_color;font-weight:600">&middot; $timing_label</span></label>
818 <div style="display:flex;gap:6px;align-items:center;font-size:12px;color:#94a3b8">
819 <input class="input" name="offset_days" type="number" value="$s->{offset_days}" style="width:60px;padding:6px 8px;text-align:center"> <span>days</span>
820 <input class="input" name="offset_hours" type="number" value="$s->{offset_hours}" style="width:60px;padding:6px 8px;text-align:center"> <span>hours</span>
821 </div>
822 </div>
823 <div style="border:1px solid rgba(34,197,94,.18);background:rgba(34,197,94,.04);border-radius:8px;padding:8px 12px;display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap">
824 <div>
825 <label style="display:block;font-size:10px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Send at local hour</label>
826 <div style="display:flex;gap:6px;align-items:center;font-size:12px;color:#94a3b8">
827 <input class="input" name="send_at_local_hour" type="number" min="0" max="23" value="$hr_val" placeholder="any" style="width:60px;padding:6px 8px;text-align:center"> <span>:00</span>
828 </div>
829 </div>
830 <label style="font-size:13px;color:#cbd5e1;display:flex;align-items:center;gap:8px;margin:0 0 6px 0;cursor:pointer">
831 <input type="checkbox" name="is_active" value="1" $checked style="width:16px;height:16px">
832 <span>Active</span>
833 </label>
834 </div>
835 </div>
836
837 <div style="display:flex;gap:10px;justify-content:flex-end;align-items:flex-start">
838 <button type="submit" class="btn btn-primary" style="font-size:13px;padding:8px 18px">Save changes</button>
839 <button type="button"
840 class="js-delete-schedule"
841 data-id="$s->{id}"
842 data-name="$name"
843 data-slug="$s->{slug}"
844 style="font-size:13px;padding:8px 18px;background:rgba(239,68,68,.10);color:#fca5a5;border:1px solid rgba(239,68,68,.35);border-radius:6px;cursor:pointer;font-weight:600">
845 Delete
846 </button>
847 </div>
848 </form>
849 </td>
850 </tr>~;
851 }
852
853 my $tpl_opts = '';
854 foreach my $t (@templates) {
855 my $n = _emx_h($t->{name});
856 $tpl_opts .= qq~<option value="$t->{id}">[$t->{category}] $n ($t->{slug})</option>~;
857 }
858 my $ev_opts = '';
859 foreach my $e (@EVENTS) {
860 my $desc = _emx_h($EVENT_DESC{$e});
861 $ev_opts .= qq~<option value="$e">$e &ndash; $desc</option>~;
862 }
863
864 my $tabs_html = _emx_email_setup_tabs('schedules');
865
866 my $body = qq~
867<style>
868 /* ---------- Confirmation modal: in-app, dark theme, animated ---------- */
869 .cmodal-back {
870 position: fixed; inset: 0; z-index: 9000;
871 background: rgba(8, 10, 16, .75);
872 backdrop-filter: blur(4px);
873 -webkit-backdrop-filter: blur(4px);
874 display: none; align-items: center; justify-content: center;
875 padding: 24px;
876 opacity: 0;
877 transition: opacity .18s ease;
878 }
879 .cmodal-back.is-open { display: flex; opacity: 1; }
880 .cmodal {
881 width: 100%; max-width: 460px;
882 background: linear-gradient(180deg, #161b29 0%, #0d1119 100%);
883 border: 1px solid rgba(255,255,255,.10);
884 border-radius: 16px;
885 box-shadow: 0 24px 70px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.02) inset;
886 overflow: hidden;
887 transform: translateY(8px) scale(.98);
888 transition: transform .18s ease;
889 }
890 .cmodal-back.is-open .cmodal { transform: translateY(0) scale(1); }
891 .cmodal-head {
892 padding: 22px 24px 14px;
893 display: flex; align-items: flex-start; gap: 14px;
894 }
895 .cmodal-icon {
896 flex: 0 0 auto; width: 40px; height: 40px;
897 border-radius: 10px;
898 background: linear-gradient(135deg, rgba(239,68,68,.20), rgba(239,68,68,.08));
899 border: 1px solid rgba(239,68,68,.35);
900 display: flex; align-items: center; justify-content: center;
901 color: #fca5a5;
902 }
903 .cmodal-titlewrap { flex: 1; }
904 .cmodal-title { font-size: 17px; font-weight: 700; color: #f1f5f9; line-height: 1.3; margin: 4px 0 0; }
905 .cmodal-body {
906 padding: 0 24px 16px 78px;
907 font-size: 13.5px; color: #cbd5e1; line-height: 1.6;
908 }
909 .cmodal-body strong { color: #e2e8f0; }
910 .cmodal-body .cmodal-target {
911 display: inline-flex; gap: 8px; align-items: center;
912 margin-top: 10px;
913 padding: 8px 12px;
914 background: rgba(255,255,255,.03);
915 border: 1px solid rgba(255,255,255,.06);
916 border-radius: 8px;
917 font-size: 12.5px;
918 }
919 .cmodal-body .cmodal-target code {
920 font-family: ui-monospace,Consolas,Menlo,monospace;
921 font-size: 11.5px; color: #c4b5fd;
922 }
923 .cmodal-foot {
924 padding: 14px 22px 22px;
925 display: flex; justify-content: flex-end; gap: 10px;
926 background: rgba(0,0,0,.18);
927 border-top: 1px solid rgba(255,255,255,.05);
928 }
929 .cmodal-btn {
930 border: 0; border-radius: 9px;
931 padding: 10px 18px;
932 font-size: 13.5px; font-weight: 600;
933 cursor: pointer;
934 transition: background .12s, transform .08s;
935 font-family: inherit;
936 }
937 .cmodal-btn:active { transform: translateY(1px); }
938 .cmodal-btn-cancel {
939 background: rgba(255,255,255,.06);
940 color: #cbd5e1;
941 border: 1px solid rgba(255,255,255,.10);
942 }
943 .cmodal-btn-cancel:hover { background: rgba(255,255,255,.10); }
944 .cmodal-btn-danger {
945 background: linear-gradient(180deg, #dc2626, #b91c1c);
946 color: #fff;
947 border: 1px solid rgba(127,29,29,.6);
948 box-shadow: 0 1px 0 rgba(255,255,255,.10) inset, 0 6px 16px rgba(220,38,38,.30);
949 }
950 .cmodal-btn-danger:hover { background: linear-gradient(180deg, #ef4444, #dc2626); }
951</style>
952
953<div class="page-pad">
954 <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email Setup</h1>
955 <p style="color:#94a3b8;margin:0 0 18px;font-size:14px">Templates, schedules, and spam-protection controls.</p>
956 $tabs_html
957 $flash_html
958 <h2 style="font-size:18px;color:#fff;margin:0 0 6px">Schedules</h2>
959 <p style="color:#94a3b8;margin:0 0 14px;font-size:13px">When each lifecycle email fires. Negative offset = before the trigger event, positive = after.</p>
960
961 <div style="border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:rgba(20,20,30,.4);margin-bottom:32px">
962 <table style="width:100%;border-collapse:collapse">
963 <thead><tr>
964 <th style="padding:12px 14px;text-align:left;font-size:10.5px;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em;width:180px">Template</th>
965 <th style="padding:12px 14px;text-align:left;font-size:10.5px;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em">Trigger + offset</th>
966 </tr></thead>
967 <tbody>$rows</tbody>
968 </table>
969 </div>
970
971 <h2 style="font-size:18px;color:#fff;margin:0 0 14px">Add new schedule</h2>
972 <form method="POST" action="/admin_email_schedules.cgi" style="display:grid;grid-template-columns:1fr 1fr 100px 100px auto;gap:10px;align-items:end;padding:16px;border:1px solid rgba(91,33,182,.30);background:rgba(91,33,182,.05);border-radius:10px">
973 <input type="hidden" name="act" value="add">
974 <div><label style="display:block;font-size:11px;color:#cbd5e1;margin-bottom:4px;text-transform:uppercase">Template</label>
975 <select name="template_id" class="select" required>$tpl_opts</select></div>
976 <div><label style="display:block;font-size:11px;color:#cbd5e1;margin-bottom:4px;text-transform:uppercase">Trigger</label>
977 <select name="trigger_event" class="select">$ev_opts</select></div>
978 <div><label style="display:block;font-size:11px;color:#cbd5e1;margin-bottom:4px;text-transform:uppercase">Days</label>
979 <input class="input" name="offset_days" type="number" value="0" style="text-align:center"></div>
980 <div><label style="display:block;font-size:11px;color:#cbd5e1;margin-bottom:4px;text-transform:uppercase">Hours</label>
981 <input class="input" name="offset_hours" type="number" value="0" style="text-align:center"></div>
982 <div><button type="submit" class="btn btn-primary">+ Add schedule</button></div>
983 </form>
984</div>
985
986<!-- ===== Confirmation modal (hidden by default) ===== -->
987<div class="cmodal-back" id="cmodal-back" role="dialog" aria-modal="true" aria-labelledby="cmodal-title">
988 <div class="cmodal">
989 <div class="cmodal-head">
990 <div class="cmodal-icon">
991 <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
992 <path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
993 <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
994 <line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>
995 </svg>
996 </div>
997 <div class="cmodal-titlewrap">
998 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#fca5a5;font-weight:600">Confirm deletion</div>
999 <h2 class="cmodal-title" id="cmodal-title">Delete this schedule?</h2>
1000 </div>
1001 </div>
1002 <div class="cmodal-body">
1003 The template itself stays in place &mdash; only this scheduled send rule is removed. Other schedules for the same template are unaffected.
1004 <div class="cmodal-target" id="cmodal-target">
1005 <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#94a3b8" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
1006 <span id="cmodal-target-name" style="color:#e2e8f0;font-weight:500"></span>
1007 <code id="cmodal-target-slug"></code>
1008 </div>
1009 </div>
1010 <div class="cmodal-foot">
1011 <button type="button" class="cmodal-btn cmodal-btn-cancel" id="cmodal-cancel">Cancel</button>
1012 <button type="button" class="cmodal-btn cmodal-btn-danger" id="cmodal-confirm">Delete schedule</button>
1013 </div>
1014 </div>
1015</div>
1016
1017<!-- Hidden form that the modal submits when the user confirms -->
1018<form id="cmodal-form" method="POST" action="/admin_email_schedules.cgi" style="display:none">
1019 <input type="hidden" name="act" value="delete">
1020 <input type="hidden" name="schedule_id" id="cmodal-form-id" value="">
1021</form>
1022
1023<script>
1024(function () {
1025 var back = document.getElementById('cmodal-back');
1026 var nameEl = document.getElementById('cmodal-target-name');
1027 var slugEl = document.getElementById('cmodal-target-slug');
1028 var idEl = document.getElementById('cmodal-form-id');
1029 var form = document.getElementById('cmodal-form');
1030 var btnC = document.getElementById('cmodal-cancel');
1031 var btnD = document.getElementById('cmodal-confirm');
1032
1033 function open(name, slug, id) {
1034 nameEl.textContent = name;
1035 slugEl.textContent = slug;
1036 idEl.value = id;
1037 back.classList.add('is-open');
1038 document.body.style.overflow = 'hidden';
1039 setTimeout(function(){ btnC.focus(); }, 50);
1040 }
1041 function close() {
1042 back.classList.remove('is-open');
1043 document.body.style.overflow = '';
1044 }
1045 document.querySelectorAll('.js-delete-schedule').forEach(function (b) {
1046 b.addEventListener('click', function (ev) {
1047 ev.preventDefault();
1048 open(b.getAttribute('data-name'), b.getAttribute('data-slug'), b.getAttribute('data-id'));
1049 });
1050 });
1051 btnC.addEventListener('click', close);
1052 btnD.addEventListener('click', function () { form.submit(); });
1053 back.addEventListener('click', function (ev) { if (ev.target === back) close(); });
1054 document.addEventListener('keydown', function (ev) {
1055 if (ev.key === 'Escape' && back.classList.contains('is-open')) close();
1056 });
1057})();
1058</script>~;
1059
1060 $wrap->render({ userinfo => $u, page_key => 'email_setup', title => 'Email Setup', body => $body });
1061 return;
1062}
1063
1064#======================================================================
1065# Shared helpers (prefixed to avoid collisions).
1066#======================================================================
1067sub _emx_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; }
1068
1069sub _emx_email_setup_tabs {
1070 my ($active) = @_;
1071 my %t = (templates => 'Templates', schedules => 'Schedules', controls => 'Controls');
1072 my %h = (templates => '/admin_emails.cgi', schedules => '/admin_email_schedules.cgi', controls => '/admin_email_controls.cgi');
1073 my $html = '<div style="display:flex;gap:0;border-bottom:1px solid rgba(255,255,255,.08);margin:0 0 22px">';
1074 foreach my $k (qw(templates schedules controls)) {
1075 my $is_active = ($k eq $active);
1076 my $color = $is_active ? '#5aa9ff' : '#94a3b8';
1077 my $border = $is_active ? '#5aa9ff' : 'transparent';
1078 my $weight = $is_active ? '600' : '500';
1079 $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>~;
1080 }
1081 $html .= '</div>';
1082 return $html;
1083}