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:
parent
5d9306b75c
commit
3725374ade
2 changed files with 268 additions and 4 deletions
|
|
@ -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)"
|
||||
|
|
|
|||
251
scripts/workers/create_ct_annual_report_campaign.py
Normal file
251
scripts/workers/create_ct_annual_report_campaign.py
Normal 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 — 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 start with a quick, free check of your "
|
||||
"Connecticut business record:")
|
||||
+ cta("Run my free CT compliance check →", _track(f"{SITE}/tools/dot-compliance-check", "ctcheck"))
|
||||
+ 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue