- 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.
196 lines
11 KiB
Python
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;"> </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;">→</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. · <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
|