Was deep-linking ?name={{QueryEscape company}} which (a) risked the @TrackLink
per-subscriber 404 bug and (b) QueryEscape isn't a guaranteed Listmonk template
func + company names have commas/spaces that break the query. Now a clean static
?state=CT link (pre-selects CT, @TrackLink-safe) and the body names the company
in text so the recipient knows to look it up. Funnel verified live end-to-end.
252 lines
12 KiB
Python
252 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
create_ct_annual_report_campaign.py — Connecticut SOS annual-report overdue outreach.
|
|
|
|
Audience : the verified smtp_valid, NON-Google CT leads (annual report overdue),
|
|
staged CSV from the registry pipeline + verify step.
|
|
Hook : the business's CT annual report is overdue -> risk of administrative
|
|
dissolution / loss of good standing. We file it + keep them compliant.
|
|
Sender : Performance West <noreply@filings.performancewest.net> (the fresh
|
|
subdomain on the fresh .90 IP, so this warms a clean reputation).
|
|
Product : CT annual report filing + registered agent + reinstatement (services
|
|
Performance West already fulfills).
|
|
|
|
Personalization (per-subscriber Listmonk attribs, loaded from the CSV):
|
|
company, due_date, days_overdue, city
|
|
|
|
DRAFT-only by default. Loads leads to a fresh list, builds a personalized HTML +
|
|
plaintext campaign. Sending is a separate, throttled, warm-up step.
|
|
|
|
python3 scripts/workers/create_ct_annual_report_campaign.py --csv /tmp/ct_send_nongoogle.csv --limit 100
|
|
python3 scripts/workers/create_ct_annual_report_campaign.py --preview-only
|
|
"""
|
|
import argparse
|
|
import csv
|
|
import os
|
|
import sys
|
|
import datetime as dt
|
|
|
|
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
if _REPO not in sys.path:
|
|
sys.path.insert(0, _REPO)
|
|
|
|
from scripts._email_plaintext import html_to_text # noqa: E402
|
|
|
|
SITE = "https://performancewest.net"
|
|
FROM_EMAIL = "Performance West <noreply@filings.performancewest.net>"
|
|
REPLY_TO = "info@performancewest.net"
|
|
EMAIL = "info@performancewest.net"
|
|
PHONE_TEL = "+18884110383"
|
|
PHONE_DISPLAY = "(888) 411-0383"
|
|
FONT = "Inter,system-ui,Arial,sans-serif"
|
|
|
|
# The soft CTA lands on the free compliance-scan tool (warm, no-pressure).
|
|
UTM = "utm_source=filings&utm_medium=email&utm_campaign=ct-annual-report"
|
|
|
|
|
|
def _track(url, content):
|
|
sep = "&" if "?" in url else "?"
|
|
return f"{url}{sep}{UTM}&utm_content={content}@TrackLink"
|
|
|
|
|
|
# ── HTML helpers (clean, professional, low-spam) ─────────────────────────────
|
|
def header():
|
|
return (
|
|
'<tr><td style="background:#1a2744;padding:24px 28px;">'
|
|
f'<img src="{SITE}/images/logo.png" alt="Performance West" style="height:42px;display:block" />'
|
|
'</td></tr>'
|
|
)
|
|
|
|
|
|
def P(t):
|
|
return f'<p style="font-size:15px;line-height:1.7;margin:0 0 16px;color:#1f2937;font-family:{FONT};">{t}</p>'
|
|
|
|
|
|
def cta(text, url):
|
|
return (
|
|
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:6px 0 22px;"><tr>'
|
|
'<td style="text-align:center;padding:6px 0;">'
|
|
f'<a href="{url}" style="display:inline-block;padding:14px 40px;background:#1e40af;color:#fff;'
|
|
f'font-weight:700;border-radius:8px;text-decoration:none;font-size:15px;font-family:{FONT};">{text}</a>'
|
|
'</td></tr></table>'
|
|
)
|
|
|
|
|
|
def status_box():
|
|
# Uses Listmonk template vars filled from attribs.
|
|
return (
|
|
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 20px;"><tr>'
|
|
'<td style="background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:16px 18px;">'
|
|
f'<p style="margin:0 0 4px;font-size:13px;color:#9a3412;font-weight:700;font-family:{FONT};">'
|
|
'Connecticut annual report — past due</p>'
|
|
f'<p style="margin:0;font-size:14px;color:#7c2d12;line-height:1.6;font-family:{FONT};">'
|
|
'<strong>{{ .Subscriber.Attribs.company }}</strong><br>'
|
|
'Annual report was due <strong>{{ .Subscriber.Attribs.due_date }}</strong> '
|
|
'({{ .Subscriber.Attribs.days_overdue }} days ago).</p>'
|
|
'</td></tr></table>'
|
|
)
|
|
|
|
|
|
def footer():
|
|
return (
|
|
'<tr><td style="padding:18px 28px;background:#f8fafc;border-top:1px solid #e5e7eb;text-align:center;">'
|
|
f'<p style="margin:0 0 8px;font-size:12px;color:#6b7280;font-family:{FONT};">'
|
|
f'Questions? Reply to this email, write <a href="mailto:{EMAIL}" style="color:#1e40af;text-decoration:none;">{EMAIL}</a> '
|
|
f'or call <a href="tel:{PHONE_TEL}" style="color:#1e40af;text-decoration:none;">{PHONE_DISPLAY}</a>.</p>'
|
|
f'<p style="margin:0;font-size:11px;color:#9ca3af;font-family:{FONT};">Performance West Inc. · '
|
|
'525 Randall Ave Ste 100-1195, Cheyenne, WY 82001 · '
|
|
f'<a href="{SITE}" style="color:#6b7280;">performancewest.net</a></p>'
|
|
'<p style="margin:6px 0 0;font-size:11px;color:#9ca3af;font-family:Inter,sans-serif;">'
|
|
'You received this because your business is listed as active in the Connecticut public '
|
|
'business registry. <a href="{{ UnsubscribeURL }}" style="color:#6b7280;">Unsubscribe</a></p>'
|
|
'</td></tr>'
|
|
)
|
|
|
|
|
|
def build_body():
|
|
inner = (
|
|
P("Hi {{ .Subscriber.Attribs.company }},")
|
|
+ P("A quick heads-up from Performance West: according to the Connecticut Secretary of the "
|
|
"State's public business registry, your company’s <strong>annual report is past due</strong>.")
|
|
+ status_box()
|
|
+ P("In Connecticut, an overdue annual report can put your business out of <strong>good standing</strong> "
|
|
"and, if it stays unfiled, can eventually lead to <strong>administrative dissolution</strong> — "
|
|
"which can affect your ability to get financing, sign contracts, or renew licenses.")
|
|
+ P("The good news: it’s a quick fix. <strong>We can file your Connecticut annual report for you</strong>, "
|
|
"confirm your business is back in good standing, and set a reminder so it never lapses again. If you’d "
|
|
"like, we can also handle your registered-agent service and any reinstatement paperwork.")
|
|
+ P("Want us to take care of it? Reply to this email, or look up <strong>{{ .Subscriber.Attribs.company }}</strong> "
|
|
"in our free Connecticut business-standing checker to see your exact status and what it costs to fix:")
|
|
+ cta("Check my CT business standing →",
|
|
_track(f"{SITE}/tools/corporation-check?state=CT", "corpcheck"))
|
|
+ P("If you’ve already filed, no action needed — and congratulations on staying current.")
|
|
+ P("Best,<br><strong>Justin Hannah</strong><br>Performance West Inc.<br>"
|
|
f'<span style="font-size:12px;color:#64748b;font-family:{FONT};">Business Compliance Services</span>')
|
|
)
|
|
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){.pw-wrap{width:100%!important;border-radius:0!important;}'
|
|
'.pw-pad{padding:24px 16px!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;"><center>'
|
|
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eef0f3;"><tr>'
|
|
'<td style="padding:24px 10px;">'
|
|
'<table role="presentation" class="pw-wrap" width="600" cellpadding="0" cellspacing="0" '
|
|
'style="margin:0 auto;border-radius:10px;overflow:hidden;background:#fff;">'
|
|
+ header()
|
|
+ '<tr><td class="pw-pad" style="padding:28px;">' + inner + '</td></tr>'
|
|
+ footer()
|
|
+ '</table></td></tr></table></center></body></html>'
|
|
)
|
|
|
|
|
|
# ── Listmonk API (reuse the shared helper's connection) ──────────────────────
|
|
def load_leads_and_create(csv_path, limit, do_send_test):
|
|
import requests
|
|
from scripts.workers.campaign_helpers import LISTMONK_URL, AUTH
|
|
s = requests.Session(); s.auth = AUTH
|
|
|
|
# 1. read + cap the CSV
|
|
rows = []
|
|
with open(csv_path) as f:
|
|
for r in csv.DictReader(f):
|
|
if (r.get("verify_reason") or "") != "smtp_valid":
|
|
continue
|
|
if "google" in (r.get("mx_provider") or ""):
|
|
continue
|
|
rows.append(r)
|
|
if limit:
|
|
rows = rows[:limit]
|
|
print(f"leads to load: {len(rows)}")
|
|
|
|
# 2. create the fresh list
|
|
lname = f"CT Annual Report Overdue — filings warmup {dt.date.today().isoformat()}"
|
|
lid = s.post(f"{LISTMONK_URL}/api/lists", json={
|
|
"name": lname, "type": "private", "optin": "single", "tags": ["ct", "annual-report", "filings"],
|
|
}, timeout=30).json()["data"]["id"]
|
|
print(f"list id: {lid}")
|
|
|
|
# 3. load subscribers with attribs
|
|
ok = 0
|
|
for r in rows:
|
|
company = (r.get("name") or "").strip().title()
|
|
attribs = {
|
|
"company": company or "your business",
|
|
"due_date": r.get("due_date", ""),
|
|
"days_overdue": r.get("days_overdue", ""),
|
|
"city": (r.get("city") or "").title(),
|
|
"entity_id": r.get("entity_id", ""),
|
|
}
|
|
try:
|
|
s.post(f"{LISTMONK_URL}/api/subscribers", json={
|
|
"email": r["email"].strip().lower(),
|
|
"name": company or r["email"],
|
|
"status": "enabled",
|
|
"lists": [lid],
|
|
"attribs": attribs,
|
|
"preconfirm_subscriptions": True,
|
|
}, timeout=30)
|
|
ok += 1
|
|
except Exception as e:
|
|
print(f" skip {r['email']}: {e}", file=sys.stderr)
|
|
print(f"loaded {ok}/{len(rows)} subscribers")
|
|
|
|
# 4. create the campaign (DRAFT)
|
|
body = build_body()
|
|
alt = html_to_text(body)
|
|
cid = s.post(f"{LISTMONK_URL}/api/campaigns", json={
|
|
"name": f"CT Annual Report Overdue — {dt.date.today().isoformat()}",
|
|
"subject": "Your Connecticut annual report is overdue — we can file it for you",
|
|
"lists": [lid],
|
|
"from_email": FROM_EMAIL,
|
|
"type": "regular", "content_type": "html",
|
|
"body": body, "altbody": alt,
|
|
"headers": [{"Reply-To": REPLY_TO}],
|
|
"messenger": "email",
|
|
}, timeout=30).json()["data"]["id"]
|
|
print(f"campaign id: {cid} (DRAFT)")
|
|
|
|
if do_send_test:
|
|
tp = {"subscribers": [do_send_test]}
|
|
base = s.get(f"{LISTMONK_URL}/api/campaigns/{cid}", timeout=30).json()["data"]
|
|
tp.update({"name": base["name"], "subject": base["subject"],
|
|
"lists": [lid], "from_email": FROM_EMAIL, "type": "regular",
|
|
"content_type": "html", "body": base["body"], "altbody": base.get("altbody"),
|
|
"messenger": "email", "headers": base.get("headers")})
|
|
rt = s.post(f"{LISTMONK_URL}/api/campaigns/{cid}/test", json=tp, timeout=30)
|
|
print(f"test send to {do_send_test}: {rt.status_code}")
|
|
return cid, lid
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--csv", help="verified CT leads CSV (smtp_valid, non-Google)")
|
|
ap.add_argument("--limit", type=int, default=100, help="cap leads (warmup: start ~100)")
|
|
ap.add_argument("--preview-only", action="store_true", help="render HTML to /tmp, no Listmonk")
|
|
ap.add_argument("--send-test", help="fire a Listmonk test to this address after creating the draft")
|
|
args = ap.parse_args()
|
|
|
|
body = build_body()
|
|
out = "/tmp/ct_annual_report_email.html"
|
|
# fill sample attribs for a realistic preview
|
|
sample = (body
|
|
.replace("{{ .Subscriber.Attribs.company }}", "Shoreline Corporation")
|
|
.replace("{{ .Subscriber.Attribs.due_date }}", "2026-06-29")
|
|
.replace("{{ .Subscriber.Attribs.days_overdue }}", "2")
|
|
.replace("{{ UnsubscribeURL }}", "https://lists.performancewest.net/unsub/preview"))
|
|
with open(out, "w") as f:
|
|
f.write(sample)
|
|
print(f"preview -> {out} ({len(body):,} chars HTML / {len(html_to_text(body)):,} plaintext)")
|
|
|
|
if args.preview_only:
|
|
return 0
|
|
if not args.csv:
|
|
print("no --csv; preview only. Re-run with --csv to load leads + create draft.", file=sys.stderr)
|
|
return 0
|
|
load_leads_and_create(args.csv, args.limit, args.send_test)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|