ct campaign + fix filings routing durability

- create_ct_annual_report_campaign.py: personalized CT annual-report overdue
  email (company/due_date/days_overdue attribs), from filings.* subdomain, soft
  free-compliance-check CTA. HTML+plaintext, List-Unsubscribe, @TrackLink.
- pw-mta-warmup.sh: when the rotation pool is a single IP, write plain
  transport_maps (no catch-all randmap) + default_transport, so the filings
  sender_dependent map (-> outfilings/.90) survives the daily warmup run.
  Previously randmap:{out05:} clobbered filings routing every day at 07:17 UTC.

Verified end-to-end: Listmonk CT test egresses .90 (outfilings), DKIM
d=filings.performancewest.net s=filings, multipart HTML+plaintext, attribs
render (Riverside Landscaping LLC / due 2026-06-15 / 16 days ago), 0 stray
tokens, real per-subscriber unsubscribe link.
This commit is contained in:
justin 2026-07-01 08:14:12 -05:00
parent 5d9306b75c
commit 3725374ade
2 changed files with 268 additions and 4 deletions

View file

@ -59,13 +59,26 @@ if [ "$N" -gt "${#ALL[@]}" ]; then N=${#ALL[@]}; fi
POOL=""
for ((i=0; i<N; i++)); do POOL="${POOL}${ALL[$i]}:,"; done
POOL="${POOL%,}"
NEWVAL="hash:/etc/postfix/transport, randmap:{${POOL}}"
# Filings-safe: when the pool is a single IP (N==1, the consolidated out05),
# use a plain transport_maps (NO catch-all randmap) + default_transport, so the
# sender_dependent_default_transport_maps filings->outfilings route can win
# (a recipient-side randmap catch-all would otherwise outrank the sender map and
# force filings mail onto out05/.94, defeating the fresh-IP isolation).
if [ "$N" -le 1 ]; then
TVAL="hash:/etc/postfix/transport"
DVAL="${ALL[0]}"
else
TVAL="hash:/etc/postfix/transport, randmap:{${POOL}}"
DVAL="smtp"
fi
CUR=$($POSTCONF -h transport_maps 2>/dev/null || echo "")
if [ "$CUR" != "$NEWVAL" ]; then
sudo $POSTCONF -e "transport_maps=${NEWVAL}"
CURD=$($POSTCONF -h default_transport 2>/dev/null || echo "")
if [ "$CUR" != "$TVAL" ] || [ "$CURD" != "$DVAL" ]; then
sudo $POSTCONF -e "transport_maps=${TVAL}"
sudo $POSTCONF -e "default_transport=${DVAL}"
sudo $POSTFIX reload >/dev/null 2>&1 || true
logger -t pw-warmup "day $DAYS -> $N rotation IPs active"
logger -t pw-warmup "day $DAYS -> $N rotation IPs active (transport_maps=$TVAL default=$DVAL)"
echo "$(date '+%F %T') warmup: day=$DAYS active_rotation_ips=$N"
else
echo "$(date '+%F %T') warmup: day=$DAYS active_rotation_ips=$N (no change)"

View file

@ -0,0 +1,251 @@
#!/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 &mdash; 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. &middot; '
'525 Randall Ave Ste 100-1195, Cheyenne, WY 82001 &middot; '
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&rsquo;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> &mdash; "
"which can affect your ability to get financing, sign contracts, or renew licenses.")
+ P("The good news: it&rsquo;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&rsquo;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 start with a quick, free check of your "
"Connecticut business record:")
+ cta("Run my free CT compliance check &rarr;", _track(f"{SITE}/tools/dot-compliance-check", "ctcheck"))
+ P("If you&rsquo;ve already filed, no action needed &mdash; 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())