otc: reincorporation email template + campaign builder

otc_reincorporation.html: redomesticate-to-Texas hook (Business Court + TXSE +
DE franchise-tax cost) personalized by state_inc_name/company/ticker, cross-sell
RA/foreign-qual/annual-report/franchise-tax, same-day coupon, lead-capture CTA to
/contact?service=reincorporation (high-touch corporate service, not self-serve),
careful 'not a law firm / not legal advice' disclaimers + CAN-SPAM address.

build_otc_campaign.py: emails only verified-email issuers from the harvest+scrape
+verify pipeline, --de-nv-only for the best reincorp fit, reuses trucking sender
plumbing + coupon. Per-deal value is high so capped modestly (400/run default).
This commit is contained in:
justin 2026-06-14 06:58:43 -05:00
parent 4d3af2aeae
commit 134a2611f6
2 changed files with 196 additions and 0 deletions

View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Build the OTC issuer reincorporation/compliance outreach campaign.
Audience: US-domestic OTC SEC issuers (from harvest_otc_issuers.py +
scrape_otc_emails.py + verify). Hook: redomesticate-to-Texas for DE/NV issuers;
cross-sell registered agent / foreign qualification / annual report / franchise
tax. High per-deal value, small universe (~700-900), so NOT warmup-capped the
same way -- but still MX-aware and same-day-coupon enabled. Reuses the trucking
sender plumbing.
Input CSV (verified): cik,name,ticker,state_inc,phone,city,state,zip,domain,email
plus verify columns. Only rows with a verified email are emailed.
Usage:
python3 scripts/build_otc_campaign.py VERIFIED.csv --start-campaign
python3 scripts/build_otc_campaign.py VERIFIED.csv --preview
python3 scripts/build_otc_campaign.py VERIFIED.csv --dry-run
"""
from __future__ import annotations
import argparse
import csv
import os
import sys
from datetime import datetime, timezone, timedelta, date
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
import psycopg2 # noqa: E402
import scripts.build_trucking_campaigns as tc # noqa: E402
LOG = tc.LOG
SOURCE_ENV = "CAMPAIGN_OTC_REINCORP_ID"
LP_LINK = f"{tc.SITE_DOMAIN}/contact?service=reincorporation"
STATE_NAMES = {
"DE": "Delaware", "NV": "Nevada", "CA": "California", "NY": "New York",
"FL": "Florida", "TX": "Texas", "CO": "Colorado", "WY": "Wyoming",
"MD": "Maryland", "IA": "Iowa", "MN": "Minnesota", "NJ": "New Jersey",
}
def verified_ok(r: dict) -> bool:
e = (r.get("email") or "").strip()
if not e or "@" not in e:
return False
vr = (r.get("verify_ok") or r.get("verify_reason") or "").strip().lower()
# Accept if verifier passed, or if the column is absent (treat present email
# as usable -- but prefer running verify first).
if "verify_ok" in r:
return str(r.get("verify_ok")).strip().upper() in ("Y", "YES", "TRUE", "1")
return True
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("infile")
ap.add_argument("--start-campaign", action="store_true")
ap.add_argument("--preview", action="store_true")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--limit", type=int, default=int(os.getenv("OTC_DAILY_CAP", "400")))
ap.add_argument("--de-nv-only", action="store_true",
help="only Delaware/Nevada issuers (best reincorp fit)")
args = ap.parse_args()
import logging
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(message)s")
rows = [r for r in csv.DictReader(open(args.infile, newline="", encoding="utf-8"))
if verified_ok(r)]
if args.de_nv_only:
rows = [r for r in rows if (r.get("state_inc") or "").strip() in ("DE", "NV")]
LOG.info("[otc] %d emailable issuers (de_nv_only=%s)", len(rows), args.de_nv_only)
src = os.getenv(SOURCE_ENV)
if not src:
LOG.error("[otc] %s not set -- create the OTC source campaign first", SOURCE_ENV)
return 2
base = tc.get_base_campaign(int(src))
conn = psycopg2.connect(tc.DB_URL)
coupon = None
if args.start_campaign and not args.preview and not args.dry_run:
try:
coupon = tc.get_or_create_daily_coupon(conn, date.today())
except Exception as exc: # noqa: BLE001
LOG.warning("[otc] coupon mint failed: %s", exc)
def attribs(r):
si = (r.get("state_inc") or "").strip()
return {
"company": r.get("name", ""), "ticker": r.get("ticker", ""),
"state_inc": si, "state_inc_name": STATE_NAMES.get(si, si or "your state"),
"lp_link": LP_LINK,
**tc.coupon_attribs(coupon),
}
if args.preview:
subs = [{"email": tc.TEST_EMAIL, "name": "Sample Issuer",
"attribs": attribs({"name": "Sample Issuer Inc.", "ticker": "SMPL",
"state_inc": "DE"})}]
else:
subs = []
seen = set()
for r in rows[:args.limit]:
e = r["email"].strip().lower()
if e in seen:
continue
seen.add(e)
subs.append({"email": e, "name": r.get("name", e), "attribs": attribs(r)})
today = date.today().isoformat()
list_name = f"OTC Reincorporation - {today}"
campaign_name = f"OTC Reincorporation - {date.today().strftime('%b %d %Y')}"
if args.dry_run:
LOG.info("[otc] DRY RUN -- would email %d issuers (coupon=%s)", len(subs), coupon)
return 0
list_id = tc.create_list(list_name)
added = tc.import_subscribers(list_id, subs)
LOG.info("[otc] list %d: %d/%d added", list_id, added, len(subs))
if added == 0:
LOG.error("[otc] 0 added; aborting")
return 1
send_at = datetime.now(timezone.utc) + timedelta(minutes=5)
cid = tc.create_and_schedule_campaign(
base, list_id, campaign_name, send_at, schedule=args.start_campaign and not args.preview)
LOG.info("[otc] campaign %d created (%s)", cid,
"scheduled" if args.start_campaign and not args.preview else "draft")
conn.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())