From 134a2611f66324bd1bc33b7e5103b80a1d525d81 Mon Sep 17 00:00:00 2001 From: justin Date: Sun, 14 Jun 2026 06:58:43 -0500 Subject: [PATCH] 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). --- .../otc_reincorporation.html | 57 +++++++ scripts/build_otc_campaign.py | 139 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 data/corporate_campaigns/otc_reincorporation.html create mode 100644 scripts/build_otc_campaign.py diff --git a/data/corporate_campaigns/otc_reincorporation.html b/data/corporate_campaigns/otc_reincorporation.html new file mode 100644 index 0000000..ca92178 --- /dev/null +++ b/data/corporate_campaigns/otc_reincorporation.html @@ -0,0 +1,57 @@ + +
+
+ + + + + + + + + + + +
diff --git a/scripts/build_otc_campaign.py b/scripts/build_otc_campaign.py new file mode 100644 index 0000000..97ca16c --- /dev/null +++ b/scripts/build_otc_campaign.py @@ -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())