new-site/scripts/workers/campaign_helpers.py
justin 2611b5458b CRTC USF campaign: shared campaign_helpers + Q3 38.8% USF email builder
- campaign_helpers.py: extract the branded Listmonk HTML helpers (hdr/flagbar/
  stats/cta/footer/P/UL/etc.) + create_campaign() from create_campaigns.py into
  a side-effect-free shared module; create_campaign() now takes an altbody so
  every campaign ships a plaintext alternative (deliverability).
- create_crtc_usf_campaign.py: build the one-off CRTC email hooked on the Q3
  2026 USF factor (38.8%, +1.8pts, eff Jul 1), with a $200-off CANADA200 banner
  (expires Fri 23:59 ET, CTA links carry ?code= for auto-apply), the full US
  carrier burden vs Canada advantage, BC/ON incorporation, and a hosted
  carrier-guide PDF download. Creates a DRAFT only; sending stays manual.
2026-06-17 23:40:01 -05:00

196 lines
11 KiB
Python

#!/usr/bin/env python3
"""
campaign_helpers.py — Shared Listmonk campaign building blocks.
Branded, email-client-safe HTML helpers (header, flag bar, stat tiles, CTA
button, footer, paragraphs, lists) plus the Listmonk POST helper. Extracted
from create_campaigns.py so multiple campaign scripts (the original 7-email
drips and one-off promos like the CRTC USF campaign) can share ONE source of
truth for the look-and-feel and the API plumbing.
Importing this module has NO side effects (it does not POST anything).
"""
import sys
import requests
# ── Listmonk connection ──────────────────────────────────────────────────────
LISTMONK_URL = "https://lists.performancewest.net"
AUTH = ("api", "6X1rKPea61N4rZ1S65Hx5zvqzbCj30F6nvEe9oVGH_Y")
# ── Brand constants ──────────────────────────────────────────────────────────
URL = "https://performancewest.net/order/canada-crtc"
PHONE = "1-888-411-0383"
EMAIL = "info@performancewest.net"
CONTACT = (
f'Email <a href="mailto:{EMAIL}" style="color:#e63f2a;text-decoration:none;">{EMAIL}</a> '
f'or call <a href="tel:+18884110383" style="color:#e63f2a;text-decoration:none;">{PHONE}</a>'
)
# ── HTML helpers ─────────────────────────────────────────────────────────────
def hdr(eyebrow, headline, sub=None):
s = f'<p style="margin:8px 0 0;font-family:Arial,sans-serif;font-size:13px;color:#a0b4cc;line-height:1.5;">{sub}</p>' if sub else ''
return (
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#1a2744;padding:0;">'
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding:18px 40px 14px;">'
'<table cellpadding="0" cellspacing="0" border="0"><tr>'
'<td style="vertical-align:middle;padding-right:12px;"><img src="https://performancewest.net/images/logo.png" width="90" alt="Performance West" style="display:block;width:90px;height:auto;"></td>'
'<td style="vertical-align:middle;border-left:1px solid #2d4e78;padding-left:12px;"><span style="color:#8fa8d0;font-family:Arial,sans-serif;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;">Telecom Services</span></td>'
'</tr></table></td></tr></table>'
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e63f2a;height:3px;font-size:0;line-height:0;">&nbsp;</td></tr></table>'
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding:28px 40px 32px;">'
f'<p style="margin:0 0 8px;font-family:Arial,sans-serif;font-size:11px;color:#e63f2a;letter-spacing:2px;text-transform:uppercase;font-weight:600;">{eyebrow}</p>'
f'<h1 style="margin:0;font-family:Arial,sans-serif;font-size:24px;font-weight:700;color:#ffffff;line-height:1.3;">{headline}</h1>{s}'
'</td></tr></table></td></tr></table>'
)
def flagbar(left, right):
return (
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#f0f4ff;padding:10px 40px;border-bottom:1px solid #dde5f0;">'
'<table cellpadding="0" cellspacing="0" border="0"><tr>'
'<td style="padding-right:16px;vertical-align:middle;"><img src="https://performancewest.net/images/flags/usa.png" width="22" height="14" alt="USA" style="display:inline;vertical-align:middle;">'
f'<span style="font-family:Arial,sans-serif;font-size:12px;color:#4a5568;margin-left:6px;vertical-align:middle;">{left}</span></td>'
'<td style="padding:0 16px;color:#cbd5e0;font-size:14px;vertical-align:middle;">&rarr;</td>'
'<td style="vertical-align:middle;"><img src="https://performancewest.net/images/flags/canada.png" width="22" height="14" alt="Canada" style="display:inline;vertical-align:middle;">'
f'<span style="font-family:Arial,sans-serif;font-size:12px;color:#4a5568;margin-left:6px;vertical-align:middle;">{right}</span></td>'
'</tr></table></td></tr></table>'
)
def stats(*items):
w = 100 // len(items)
cells = "".join(
f'<td width="{w}%" style="padding:0 6px;"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>'
f'<td style="background:#1a2744;border-radius:6px;padding:18px 12px;text-align:center;">'
f'<div style="font-family:Arial,sans-serif;font-size:26px;font-weight:700;color:#ffffff;line-height:1;">{v}</div>'
f'<div style="font-family:Arial,sans-serif;font-size:11px;color:#8fa8d0;margin-top:6px;line-height:1.4;">{l}</div>'
'</td></tr></table></td>'
for v, l in items
)
return f'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;"><tr>{cells}</tr></table>'
def cta(text, url):
return (
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center">'
'<table cellpadding="0" cellspacing="0" border="0" style="margin:28px auto;"><tr>'
f'<td style="background:#e63f2a;border-radius:4px;"><a href="{url}" style="display:inline-block;padding:14px 36px;font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;">{text}</a></td>'
'</tr></table></td></tr></table>'
)
def carriers_block():
data = [
("Twilio","NASDAQ: TWLO"),("Bandwidth","NASDAQ: BAND"),("Telnyx","Washington DC"),("RingCentral","NYSE: RNG"),
("Vonage","Holmdel NJ"),("8x8","NASDAQ: EGHT"),("Zoom Phone","NASDAQ: ZM"),("Dialpad","San Ramon CA"),
("Google Voice","Alphabet / GOOGL"),("Ooma","NYSE: OOMA"),("Onvoy / Sinch","OMX: SINCH"),("Sangoma","NASDAQ: SANG"),
]
rows = ""
for i in range(0, len(data), 4):
cells = "".join(
f'<td width="25%" style="padding:4px;"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>'
f'<td style="background:rgba(255,255,255,0.09);border:1px solid rgba(255,255,255,0.15);border-radius:6px;padding:8px 6px;text-align:center;">'
f'<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:600;color:#ffffff;">{n}</div>'
f'<div style="font-family:Arial,sans-serif;font-size:10px;color:#fca5a5;margin-top:2px;">{t}</div>'
'</td></tr></table></td>'
for n, t in data[i:i+4]
)
rows += f"<tr>{cells}</tr>"
return (
'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:28px 0;"><tr>'
'<td style="background:#5c0a0a;border-radius:8px;padding:20px 24px;">'
'<p style="margin:0 0 14px;font-family:Arial,sans-serif;font-size:14px;font-weight:700;color:#ffffff;text-align:center;">'
'Join other US voice carriers registered to do business in Canada '
'<img src="https://performancewest.net/images/flags/canada.png" width="22" height="14" alt="Canada" style="display:inline;vertical-align:middle;margin-left:6px;"></p>'
f'<table width="100%" cellpadding="0" cellspacing="0" border="0">{rows}</table>'
'</td></tr></table>'
)
def ftr(note=""):
note_html = f'{note}<br>' if note else ''
return (
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>'
'<td style="background:#f4f5f7;padding:20px 40px;border-top:1px solid #e8ecf0;text-align:center;">'
'<img src="https://performancewest.net/images/logo.png" width="70" alt="Performance West" style="display:block;margin:0 auto 10px;width:70px;height:auto;opacity:0.5;">'
'<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:12px;color:#9ca3af;">'
'Performance West Inc. &nbsp;&middot;&nbsp; <a href="https://performancewest.net" style="color:#9ca3af;">performancewest.net</a></p>'
f'<p style="margin:0;font-family:Arial,sans-serif;font-size:11px;color:#b0b7c3;line-height:1.6;">{note_html}'
'<a href="{{ UnsubscribeURL }}" style="color:#b0b7c3;">Unsubscribe</a></p>'
'</td></tr></table>'
)
def bq(t):
return (
'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:20px 0;"><tr>'
'<td style="border-left:4px solid #e63f2a;padding:12px 20px;background:#fdf4f3;'
f'font-family:Arial,sans-serif;font-size:15px;color:#2d3748;line-height:1.7;font-style:italic;">{t}</td></tr></table>'
)
def P(t):
return f'<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;color:#2d3748;line-height:1.7;">{t}</p>'
def PS(t):
return f'<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:14px;color:#6b7280;line-height:1.7;">{t}</p>'
def H2(t):
return f'<h2 style="margin:24px 0 10px;font-family:Arial,sans-serif;font-size:17px;font-weight:700;color:#1a2744;">{t}</h2>'
def UL(*items):
return (
'<ul style="margin:0 0 16px;padding-left:22px;font-family:Arial,sans-serif;font-size:15px;color:#2d3748;line-height:1.7;">'
+ "".join(f'<li style="margin-bottom:8px;">{i}</li>' for i in items)
+ '</ul>'
)
def assemble(hdr_html, fb_html, body_html, ftr_html):
inner = (
hdr_html + fb_html
+ f'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding:32px 40px;background:#ffffff;" class="body-pad">{body_html}</td></tr></table>'
+ ftr_html
)
return (
'<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>'
'@media only screen and (max-width:600px){.wrap{width:100%!important;border-radius:0!important;}.body-pad{padding:24px 20px!important;}h1{font-size:20px!important;}}'
'body,table,td,p,a{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}table{border-collapse:collapse!important;}img{border:0;outline:none;text-decoration:none;}'
'</style></head><body style="margin:0;padding:0;background:#eef0f3;">'
'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#eef0f3;padding:20px 0;"><tr><td align="center">'
'<table width="620" cellpadding="0" cellspacing="0" border="0" class="wrap" style="width:620px;max-width:620px;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">'
f'<tr><td>{inner}</td></tr></table></td></tr></table></body></html>'
)
def create_campaign(name, subject, lists, body_html, altbody=None, status="draft"):
"""POST a campaign to Listmonk.
altbody: optional plaintext alternative. Strongly recommended for
deliverability — an HTML-only campaign is a spam signal. Pass the output of
scripts._email_plaintext.html_to_text(body_html) (or a hand-written
plaintext). When omitted, Listmonk generates its own plaintext at send time.
"""
s = requests.Session()
s.auth = AUTH
payload = {
"name": name, "subject": subject, "lists": lists,
"type": "regular", "content_type": "html",
"body": body_html, "status": status,
}
if altbody is not None:
payload["altbody"] = altbody
r = s.post(f"{LISTMONK_URL}/api/campaigns", json=payload, timeout=30)
if not r.ok:
print(f" ERROR {r.status_code}: {r.text[:200]}", file=sys.stderr)
return None
cid = r.json().get('data', {}).get('id', '?')
print(f" [{cid}] {name}")
return cid