332 lines
15 KiB
Python
332 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Create the 6 Listmonk *source* campaigns for the deficiency-flag segments.
|
|
|
|
These are draft templates that build_trucking_campaigns.py clones for each
|
|
timezone blast (subject + body + template_id are copied). They are created as
|
|
DRAFTS (never scheduled) and live in the same Listmonk install.
|
|
|
|
After running, the script prints the `CAMPAIGN_*_ID` env lines to paste into the
|
|
workers service env. Idempotent-ish: pass --dry-run to preview, and it skips a
|
|
segment if a campaign with the same name already exists (prints its id instead).
|
|
|
|
Usage (inside the workers container on prod):
|
|
python3 scripts/create_deficiency_source_campaigns.py [--dry-run]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import urllib.parse
|
|
|
|
# Allow both supported invocation styles:
|
|
# python -m scripts.create_deficiency_source_campaigns
|
|
# python3 scripts/create_deficiency_source_campaigns.py
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if ROOT not in sys.path:
|
|
sys.path.insert(0, ROOT)
|
|
|
|
from scripts import build_trucking_campaigns as b # reuse lm_api + auth + segment defs
|
|
|
|
FROM_EMAIL = "Performance West <noreply@performancewest.net>"
|
|
TEMPLATE_ID = 6 # same wrapper template as the MCS-150 source campaign
|
|
SOURCE_LIST_ID = 8 # same placeholder list used by the MCS-150/inactive source drafts
|
|
REPLY_TO = "info@performancewest.net"
|
|
PHONE = "(888) 411-0383"
|
|
PHONE_TEL = "8884110383"
|
|
|
|
# Shared chrome (header + footer). The middle is segment-specific.
|
|
_HEADER = (
|
|
'<div style="font-family:-apple-system,system-ui,sans-serif;max-width:600px;margin:0 auto">'
|
|
'<div style="background:#1a2744;padding:24px;text-align:center;border-radius:12px 12px 0 0">'
|
|
'<img src="https://performancewest.net/images/logo.png" alt="Performance West" style="height:40px">'
|
|
'</div>'
|
|
'<div style="background:#fff;border:1px solid #e2e8f0;padding:32px;border-radius:0 0 12px 12px">'
|
|
'<p style="font-size:15px;color:#374151;line-height:1.6">{{ .Subscriber.Attribs.company }},<br>'
|
|
'DOT# {{ .Subscriber.Attribs.dot_number }}</p>'
|
|
)
|
|
|
|
_FOOTER = (
|
|
'<p style="font-size:14px;color:#64748b;line-height:1.6">Or call us directly at '
|
|
f'<a href="tel:{PHONE_TEL}" style="color:#f97316;font-weight:600">{PHONE}</a>.</p>'
|
|
'<p style="font-size:14px;color:#64748b">Performance West Inc.<br>DOT Compliance Services</p>'
|
|
'</div>'
|
|
'<div style="text-align:center;padding:16px;font-size:11px;color:#94a3b8">'
|
|
'<a href="https://performancewest.net" style="color:#94a3b8">performancewest.net</a> · '
|
|
f'{PHONE}</div>'
|
|
'<div style="text-align:center;padding:0 16px 18px;font-size:11px;color:#94a3b8;line-height:1.7">'
|
|
'Gotta hit a 10-100 and pull off this channel? '
|
|
'<a href="{{ UnsubscribeURL }}" style="color:#94a3b8;text-decoration:underline">Unsubscribe here</a>.<br>'
|
|
'Performance West Inc. · 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001</div>'
|
|
'</div>'
|
|
)
|
|
|
|
|
|
def _alert(color_border, color_text, lines_html):
|
|
return (
|
|
f'<div style="background:#fef2f2;border:2px solid {color_border};border-radius:10px;'
|
|
f'padding:18px;margin:18px 0">{lines_html}</div>'
|
|
)
|
|
|
|
|
|
def _price_box(headline, sub):
|
|
return (
|
|
'<div style="background:#f0fdf4;border:2px solid #86efac;border-radius:10px;'
|
|
'padding:20px;margin:20px 0;text-align:center">'
|
|
f'<p style="font-size:18px;font-weight:700;color:#166534;margin:0 0 4px">{headline}</p>'
|
|
f'<p style="font-size:14px;color:#15803d;margin:0">{sub}</p></div>'
|
|
)
|
|
|
|
|
|
def _cta(label):
|
|
# Clicks land on the per-subscriber order page (resolved per row, incl.
|
|
# per-state overrides) via the lp_link attrib, with UTM tracking.
|
|
return (
|
|
'<div style="text-align:center;margin:24px 0">'
|
|
'<a href="{{ .Subscriber.Attribs.lp_link }}?dot={{ .Subscriber.Attribs.dot_number }}'
|
|
'&utm_source=listmonk&utm_medium=email&utm_campaign=deficiency@TrackLink" '
|
|
'style="display:inline-block;padding:14px 36px;background:#f97316;color:#fff;'
|
|
f'font-weight:700;border-radius:8px;text-decoration:none;font-size:16px">{label} →</a></div>'
|
|
)
|
|
|
|
|
|
def _dot_check_cta():
|
|
"""Secondary CTA to the free DOT compliance checker.
|
|
|
|
The primary CTA remains the highly specific order page for the detected
|
|
deficiency. This second orange button gives carriers a broader way to verify
|
|
their DOT status and see any other missing filings before they order.
|
|
"""
|
|
return (
|
|
'<div style="text-align:center;margin:8px 0 24px">'
|
|
'<p style="font-size:13px;color:#64748b;line-height:1.5;margin:0 0 10px">'
|
|
'Want to verify everything else on your DOT profile first?</p>'
|
|
f'<a href="{b.SITE_DOMAIN}/tools/dot-compliance-check?dot={{{{ .Subscriber.Attribs.dot_number }}}}'
|
|
'&utm_source=listmonk&utm_medium=email&utm_campaign=deficiency_dot_check@TrackLink" '
|
|
'style="display:inline-block;padding:13px 30px;background:#f97316;color:#fff;'
|
|
'font-weight:700;border-radius:8px;text-decoration:none;font-size:15px">'
|
|
'Run Free DOT Compliance Check →</a></div>'
|
|
)
|
|
|
|
|
|
def _body(headline, intro, bullets, price_headline, price_sub, cta_label):
|
|
bullet_html = "".join(f"<li>{x}</li>" for x in bullets)
|
|
alert = _alert(
|
|
"#fca5a5", "#991b1b",
|
|
f'<h1 style="font-size:21px;color:#dc2626;margin:0 0 10px">{headline}</h1>'
|
|
f'<p style="font-size:15px;color:#374151;line-height:1.6;margin:0 0 8px">{intro}</p>'
|
|
f'<ul style="font-size:15px;color:#374151;line-height:1.8;margin:0">{bullet_html}</ul>'
|
|
)
|
|
reassure = (
|
|
'<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:20px 0">'
|
|
'<p style="font-size:14px;color:#374151;margin:0 0 6px"><strong>Not tech savvy? No problem.</strong></p>'
|
|
'<p style="font-size:13px;color:#64748b;margin:0;line-height:1.6">One simple form, from your phone or '
|
|
'computer. No Login.gov, no government portals, no hours on hold. We handle the paperwork so you can get '
|
|
'back to trucking and making money.</p></div>'
|
|
)
|
|
return (_HEADER + alert + _price_box(price_headline, price_sub) + reassure
|
|
+ _cta(cta_label) + _dot_check_cta() + _FOOTER)
|
|
|
|
|
|
# ── Per-segment copy ────────────────────────────────────────────────────────
|
|
SEGMENTS = {
|
|
"for_hire_boc3": {
|
|
"env": "CAMPAIGN_FOR_HIRE_ID",
|
|
"name": "For-Hire Carrier — BOC-3 + UCR Required",
|
|
"subject": "DOT# {{ .Subscriber.Attribs.dot_number }} - For-hire carriers must file BOC-3 & UCR",
|
|
"headline": "You're operating for-hire without a BOC-3 process agent on file.",
|
|
"intro": "Our records show you run for-hire authority but are missing required filings. Without them you face:",
|
|
"bullets": [
|
|
"<strong>Authority suspension</strong> by the FMCSA",
|
|
"UCR penalties of <strong>up to $5,000</strong>",
|
|
"Loads refused at the scale and by brokers",
|
|
],
|
|
"price_headline": "We file your BOC-3 process agents nationwide.",
|
|
"price_sub": "Plus UCR registration if you need it. We handle everything.",
|
|
"cta": "Fix My BOC-3 & UCR",
|
|
},
|
|
"irp_ifta": {
|
|
"env": "CAMPAIGN_IRP_IFTA_ID",
|
|
"name": "Interstate Carrier — IRP / IFTA Registration",
|
|
"subject": "DOT# {{ .Subscriber.Attribs.dot_number }} - You need IRP plates & an IFTA license",
|
|
"headline": "Running interstate without IRP apportioned plates or an IFTA license?",
|
|
"intro": "If you cross state lines over 26,000 lbs you must be registered. Missing it means:",
|
|
"bullets": [
|
|
"<strong>Fines and citations</strong> at every weigh station",
|
|
"Trip permits that cost far more than apportioned plates",
|
|
"Quarterly IFTA penalties and interest",
|
|
],
|
|
"price_headline": "We set up your IRP plates and IFTA license.",
|
|
"price_sub": "Base-state registration done for you, no DMV lines.",
|
|
"cta": "Get IRP & IFTA Done",
|
|
},
|
|
"intrastate_authority": {
|
|
"env": "CAMPAIGN_INTRASTATE_ID",
|
|
"name": "Intrastate Operating Authority Required",
|
|
"subject": "DOT# {{ .Subscriber.Attribs.dot_number }} - Your state requires intrastate authority",
|
|
"headline": "You're hauling inside your state without intrastate operating authority.",
|
|
"intro": "Most states require their own authority on top of your USDOT. Without it:",
|
|
"bullets": [
|
|
"<strong>Out-of-service orders</strong> at state inspections",
|
|
"Citations and impound risk on intrastate loads",
|
|
"Insurance filings rejected by the state",
|
|
],
|
|
"price_headline": "We file your state intrastate authority.",
|
|
"price_sub": "Application, insurance filings, and BOC-3 if required.",
|
|
"cta": "Get My State Authority",
|
|
},
|
|
"state_weight_tax": {
|
|
"env": "CAMPAIGN_WEIGHT_TAX_ID",
|
|
"name": "State Weight-Distance Tax Registration",
|
|
"subject": "DOT# {{ .Subscriber.Attribs.dot_number }} - You owe a state weight-distance tax",
|
|
"headline": "You operate in a state with a weight-distance / highway-use tax.",
|
|
"intro": "OR, NY, KY, NM and CT charge a per-mile tax that requires its own account. Skipping it means:",
|
|
"bullets": [
|
|
"<strong>Back taxes plus penalties and interest</strong>",
|
|
"Permits pulled at the border",
|
|
"Audit exposure on every mile run unregistered",
|
|
],
|
|
"price_headline": "We register your weight-distance tax account.",
|
|
"price_sub": "OR WMT, NY HUT, KY KYU, NM WDT, or CT HUF, done for you.",
|
|
"cta": "Register My Tax Account",
|
|
},
|
|
"state_emissions": {
|
|
"env": "CAMPAIGN_EMISSIONS_ID",
|
|
"name": "State Clean-Truck / Emissions Compliance",
|
|
"subject": "DOT# {{ .Subscriber.Attribs.dot_number }} - New clean-truck rules apply to you",
|
|
"headline": "Your state has new clean-truck / emissions reporting you may be missing.",
|
|
"intro": "CA, NY, CO, MD, NJ and MA now require fleet emissions registration. Non-compliance means:",
|
|
"bullets": [
|
|
"<strong>Registration holds</strong> on affected vehicles",
|
|
"Penalties under state clean-truck (ACT) rules",
|
|
"Loss of access to ports and regulated lanes",
|
|
],
|
|
"price_headline": "We handle your clean-truck / emissions registration.",
|
|
"price_sub": "CARB, ACT and state reporting set up for your fleet.",
|
|
"cta": "Check My Emissions Status",
|
|
},
|
|
"hazmat": {
|
|
"env": "CAMPAIGN_HAZMAT_ID",
|
|
"name": "Hazmat PHMSA Registration Required",
|
|
"subject": "DOT# {{ .Subscriber.Attribs.dot_number }} - Hazmat haulers must register with PHMSA",
|
|
"headline": "You haul hazardous materials but may be missing PHMSA registration.",
|
|
"intro": "Carriers of placardable hazmat must register annually with PHMSA. Without it:",
|
|
"bullets": [
|
|
"<strong>Civil penalties up to $89,678 per violation</strong>",
|
|
"Loads refused by shippers who verify your registration",
|
|
"Out-of-service orders for unregistered hazmat transport",
|
|
],
|
|
"price_headline": "We file your PHMSA hazmat registration.",
|
|
"price_sub": "Annual registration handled, government fee billed at cost.",
|
|
"cta": "Register My Hazmat",
|
|
},
|
|
}
|
|
|
|
|
|
def find_existing(name: str) -> int | None:
|
|
try:
|
|
res = b.lm_api("/campaigns?" + urllib.parse.urlencode({"query": name, "per_page": 100}))
|
|
for c in res.get("data", {}).get("results", []):
|
|
if c.get("name") == name:
|
|
return c["id"]
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def configured_campaign_id(cfg: dict) -> int | None:
|
|
raw = os.getenv(cfg["env"], "").strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
print(f" [{cfg['env']}] invalid configured campaign id {raw!r}; falling back to name lookup")
|
|
return None
|
|
|
|
|
|
def update_existing_campaign(campaign_id: int, cfg: dict, body: str, dry: bool) -> None:
|
|
"""Update an existing source draft body/metadata so future clones inherit it."""
|
|
existing = b.get_base_campaign(campaign_id)
|
|
payload = {
|
|
"name": existing.get("name") or cfg["name"],
|
|
"subject": cfg["subject"],
|
|
"lists": [l["id"] for l in existing.get("lists", []) if isinstance(l, dict)] or [SOURCE_LIST_ID],
|
|
"from_email": existing.get("from_email") or FROM_EMAIL,
|
|
"type": existing.get("type") or "regular",
|
|
"content_type": existing.get("content_type") or "html",
|
|
"body": body,
|
|
"altbody": existing.get("altbody"),
|
|
"template_id": existing.get("template_id") or TEMPLATE_ID,
|
|
"tags": existing.get("tags") or ["trucking", "deficiency", "source"],
|
|
"messenger": existing.get("messenger") or "email",
|
|
"headers": existing.get("headers") or [{"name": "Reply-To", "value": REPLY_TO}],
|
|
}
|
|
if dry:
|
|
print(f" [{cfg['env']}] DRY-RUN would update source campaign {campaign_id} (body {len(body)} chars)")
|
|
return
|
|
b.lm_api(f"/campaigns/{campaign_id}", payload, "PUT")
|
|
print(f" [{cfg['env']}] updated source campaign {campaign_id}")
|
|
|
|
|
|
def create_draft(seg_key: str, cfg: dict, dry: bool, update_existing: bool = False) -> int | None:
|
|
existing = configured_campaign_id(cfg) or find_existing(cfg["name"])
|
|
body = _body(cfg["headline"], cfg["intro"], cfg["bullets"],
|
|
cfg["price_headline"], cfg["price_sub"], cfg["cta"])
|
|
if existing:
|
|
if update_existing:
|
|
update_existing_campaign(existing, cfg, body, dry)
|
|
else:
|
|
print(f" [{seg_key}] already exists -> id {existing} (skipping create)")
|
|
return existing
|
|
if dry:
|
|
print(f" [{seg_key}] DRY-RUN would create '{cfg['name']}' (body {len(body)} chars)")
|
|
return None
|
|
payload = {
|
|
"name": cfg["name"],
|
|
"subject": cfg["subject"],
|
|
"lists": [SOURCE_LIST_ID], # placeholder list; clones override with the real per-TZ list
|
|
"from_email": FROM_EMAIL,
|
|
"type": "regular",
|
|
"content_type": "html",
|
|
"body": body,
|
|
"template_id": TEMPLATE_ID,
|
|
"tags": ["trucking", "deficiency", "source"],
|
|
"messenger": "email",
|
|
"headers": [{"name": "Reply-To", "value": REPLY_TO}],
|
|
}
|
|
res = b.lm_api("/campaigns", payload, "POST")
|
|
cid = res["data"]["id"]
|
|
print(f" [{seg_key}] created '{cfg['name']}' -> id {cid}")
|
|
return cid
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
ap.add_argument(
|
|
"--update-existing",
|
|
action="store_true",
|
|
help="Update matching existing source draft campaigns instead of only reporting their IDs.",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
print(f"Listmonk: {b.LISTMONK_URL}")
|
|
print(f"Creating {len(SEGMENTS)} deficiency source campaigns "
|
|
f"({'DRY-RUN' if args.dry_run else 'LIVE'}"
|
|
f"{', update existing' if args.update_existing else ''}):\n")
|
|
env_lines = []
|
|
for key, cfg in SEGMENTS.items():
|
|
cid = create_draft(key, cfg, args.dry_run, args.update_existing)
|
|
if cid:
|
|
env_lines.append(f"{cfg['env']}={cid}")
|
|
|
|
if env_lines:
|
|
print("\n=== Add these to the workers service environment ===")
|
|
for line in env_lines:
|
|
print(line)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|