Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/admin_emails.cgi
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
to beee2e993f8a
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 | ################################################################################################################################## | |
| 13 | use strict; | |
| 14 | use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com'; | |
| 15 | ||
| 16 | ||
| 17 | ############################################################# | |
| 18 | ## Variables | |
| 19 | ############################################################# | |
| 20 | ||
| 21 | ||
| 22 | ||
| 23 | ############################################################ | |
| 24 | # Modules | |
| 25 | ############################################################ | |
| 26 | use CGI; my $query = new CGI; my $form = $query->Vars; | |
| 27 | use MODS::PTMatrix::Urls; my $getlist = new MODS::PTMatrix::Urls; my $url = $getlist->urls(); | |
| 28 | use MODS::DBConnect; my $db = new MODS::DBConnect; | |
| 29 | use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper; | |
| 30 | use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name'); | |
| 31 | use MODS::PTMatrix::PM; | |
| 32 | use 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 | |
| 40 | my %rawform = %$form; | |
| 41 | ||
| 42 | ||
| 43 | ||
| 44 | ############################################################ | |
| 45 | # Program Controls | |
| 46 | ############################################################ | |
| 47 | $|=1; | |
| 48 | my $userinfo = MODS::PTMatrix::PM::require_login(); | |
| 49 | ||
| 50 | if(!$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 | ||
| 55 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_emails'; | |
| 56 | if ($entry eq 'admin_email_schedules'){&admin_email_schedules;} | |
| 57 | elsif($entry eq 'admin_email_controls') {&admin_email_controls;} | |
| 58 | else {&admin_emails;} | |
| 59 | ||
| 60 | ||
| 61 | ############################################################ | |
| 62 | # Subroutines | |
| 63 | ############################################################ | |
| 64 | sub h{ | |
| 65 | my $s = shift // ''; | |
| 66 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 67 | $s =~ s/"/"/g; $s =~ s/'/'/g; | |
| 68 | return $s; | |
| 69 | } | |
| 70 | ||
| 71 | sub 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 | ||
| 87 | sub 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 | ||
| 100 | sub 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 | ||
| 126 | sub 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">← 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> · 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">•</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">“</button> | |
| 178 | <button type="button" class="wbtn wbtn-text" data-action="code" title="Inline code"></></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">🔗</button> | |
| 182 | <button type="button" class="wbtn wbtn-text" data-cmd="insertHorizontalRule" title="Insert horizontal rule">—</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 ▶</button> | |
| 201 | <button type="button" class="wbtn wbtn-text wbtn-mode" id="wyse-mode" title="Toggle between rendered HTML and raw source">Source ⇄</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 · 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 <noreply\@ptmatrix.3dshawn.com> · 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 ⇄'; | |
| 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 ⇄'; | |
| 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 & 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 →</button> | |
| 458 | </div> | |
| 459 | </form> | |
| 460 | </div>~; | |
| 461 | } | |
| 462 | ||
| 463 | sub 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 | ||
| 545 | sub 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 | #============================================================ | |
| 592 | sub events_list{ | |
| 593 | return qw(trial_end_at signup invoice_paid invoice_failed password_reset); | |
| 594 | } | |
| 595 | ||
| 596 | sub 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 | ||
| 606 | sub 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 | ||
| 623 | sub 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 | ||
| 638 | sub 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 | ||
| 648 | sub 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">· $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 | ||
| 728 | sub 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 – $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 — 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 | #============================================================ | |
| 907 | sub 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 | ||
| 938 | sub 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">· 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 — 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 >= 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 | ||
| 1031 | sub 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 | } |