Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/admin_emails.cgi
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
to 688eedb62052
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 | #====================================================================== | |
| 29 | use strict; | |
| 30 | use warnings; | |
| 31 | ||
| 32 | use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com'; | |
| 33 | use CGI; | |
| 34 | use MODS::DBConnect; | |
| 35 | use MODS::ContactForge::Wrapper; | |
| 36 | use MODS::ContactForge::Config; | |
| 37 | use MODS::Login; | |
| 38 | use MODS::ContactForge::Mailer; | |
| 39 | ||
| 40 | my $q = CGI->new; | |
| 41 | my $form = $q->Vars; | |
| 42 | my $wrap = MODS::ContactForge::Wrapper->new; | |
| 43 | my $db = MODS::DBConnect->new; | |
| 44 | my $cfg = MODS::ContactForge::Config->new; | |
| 45 | my $DB = $cfg->settings('database_name'); | |
| 46 | ||
| 47 | my $auth = MODS::Login->new; | |
| 48 | my $u = $auth->login_verify; | |
| 49 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 50 | require MODS::ContactForge::Permissions; | |
| 51 | MODS::ContactForge::Permissions->new->require_super_admin($u); | |
| 52 | my $uid = $u->{user_id} + 0; | |
| 53 | ||
| 54 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_emails'; | |
| 55 | ||
| 56 | if ($entry eq 'admin_email_controls') { _emx_handle_controls(); exit; } | |
| 57 | if ($entry eq 'admin_email_schedules') { _emx_handle_schedules(); exit; } | |
| 58 | ||
| 59 | # ---- default entry: admin_emails.cgi (Templates tab) ---------------- | |
| 60 | my $dbh = $db->db_connect; | |
| 61 | my $act = $form->{act} || ''; | |
| 62 | my $flash = ''; | |
| 63 | ||
| 64 | # ---- POST handlers -------------------------------------------------- | |
| 65 | if (($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) --------------------------------------------- | |
| 110 | my $edit_slug = $form->{edit} || ''; | |
| 111 | $edit_slug =~ s/[^a-z0-9_-]//g; | |
| 112 | ||
| 113 | my @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 | |
| 122 | my @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 | ||
| 131 | print "Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n"; | |
| 132 | ||
| 133 | my $body; | |
| 134 | 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>~ : ''; | |
| 135 | ||
| 136 | if ($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">← 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> · 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">•</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">“</button> | |
| 187 | <button type="button" class="wbtn wbtn-text" data-action="code" title="Inline code"></></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">🔗</button> | |
| 191 | <button type="button" class="wbtn wbtn-text" data-cmd="insertHorizontalRule" title="Insert horizontal rule">—</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 ▶</button> | |
| 210 | <button type="button" class="wbtn wbtn-text wbtn-mode" id="wyse-mode" title="Toggle between rendered HTML and raw source">Source ⇄</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 · 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 <noreply\@crm.3dshawn.com> · 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 ⇄'; | |
| 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 ⇄'; | |
| 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 & 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 →</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 | ||
| 572 | exit; | |
| 573 | ||
| 574 | #====================================================================== | |
| 575 | # admin_email_controls entry: Controls tab (rate/bounce/abuse caps). | |
| 576 | #====================================================================== | |
| 577 | sub _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">· 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 — 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 >= 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 | #====================================================================== | |
| 699 | sub _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">· $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 – $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 — 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 | #====================================================================== | |
| 1067 | sub _emx_h { my $s = shift // ''; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; $s =~ s/'/'/g; return $s; } | |
| 1068 | ||
| 1069 | sub _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 | } |