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

O Operator
Diff

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

added on local at 2026-07-11 18:33:14

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