From 25f4a7503b0d73aef7eec7fa3f514b27ef5904e7 Mon Sep 17 00:00:00 2001 From: justin Date: Tue, 9 Jun 2026 20:27:47 -0500 Subject: [PATCH] warmup: IP rehab for .91-.93 so they can be reallocated The 3 IPs (mta02-04 / .91-.93) retired after the May 30-31 over-volume blast are NOT on any DNSBL (Spamhaus/Barracuda/SpamCop/SORBS all clean) and have clean PTRs + SPF/DKIM/DMARC -- the damage was provider-internal reputation, which recovers with slow clean sending. scripts/ip_rehab.py sends a tiny ramping trickle (10/IP/day -> cap 60) of genuine CAN-SPAM-compliant compliance check-in mail to clean business-domain, never-bounced recipients via dedicated heavily-throttled postfix transports rehab02/03/04 (30s/msg, bound to .91/.92/.93). Routing uses an X-PW-Rehab-IP header + header_checks FILTER to override the transport_maps randmap warmup rotation (verified: mail routes via rehab transports, status=sent). Daily cron pw-ip-rehab. After ~2-3 weeks of clean sending the IPs can be reallocated. --- infra/cron/pw-ip-rehab | 8 ++ scripts/ip_rehab.py | 199 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 infra/cron/pw-ip-rehab create mode 100644 scripts/ip_rehab.py diff --git a/infra/cron/pw-ip-rehab b/infra/cron/pw-ip-rehab new file mode 100644 index 0000000..655475b --- /dev/null +++ b/infra/cron/pw-ip-rehab @@ -0,0 +1,8 @@ +# IP reputation rehab for .91-.93 (mta02-04), retired after the May 30-31 +# over-volume blast so they can be reallocated. Sends a small daily-ramping +# trickle (10/IP day0 -> +10/day -> cap 60/IP) of genuine compliance check-in +# mail to clean business/ISP-domain, never-bounced recipients via the heavily- +# throttled rehab02/03/04 postfix transports (30s/msg, bound to each IP). This +# rebuilds provider-internal reputation. Logs to /opt/performancewest/logs +# (deploy-owned -- a /var/log redirect would make cron silently fail). +30 8 * * 1-5 deploy cd /opt/performancewest && python3 scripts/ip_rehab.py >> /opt/performancewest/logs/pw-ip-rehab.log 2>&1 diff --git a/scripts/ip_rehab.py b/scripts/ip_rehab.py new file mode 100644 index 0000000..3952cbb --- /dev/null +++ b/scripts/ip_rehab.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""IP rehab feeder for .91-.93 (mta02-04), retired after the May 30-31 over-volume +blast. Sends a small, daily-ramping trickle through the heavily-throttled rehab02/ +03/04 postfix transports so provider-internal reputation rebuilds and the IPs can +be reallocated. + +Strategy (conservative -- the goal is reputation recovery, not lead-gen): + * Recipients: enabled main-listmonk subscribers on REAL business/ISP domains + (never consumer, never previously-bounced), so every send is to a live, + low-complaint inbox -- the opposite of what torched the IPs. + * Volume: tiny and ramping (day0=10/IP, then +10/day, cap 60/IP) split across + the 3 rehab IPs, well under any provider rate limit at the 30s/transport delay. + * Content: a genuine, low-pressure compliance-update email (CAN-SPAM compliant: + real from/subject, physical address, unsubscribe). No aggressive offers. + * Sends DIRECTLY via the rehab transports (sendmail -o) so the IP binding + + throttle apply; does NOT route through the listmonk warmup pool. + +Run daily from cron AFTER the main warmup window. Idempotent per day (a state file +records the ramp day). Records who we mailed so we don't re-hit the same inbox. + +Usage: + python3 scripts/ip_rehab.py # send today's rehab trickle + python3 scripts/ip_rehab.py --dry-run # show who/what without sending + python3 scripts/ip_rehab.py --status # show ramp day + transports + DNSBL +""" +from __future__ import annotations +import argparse, datetime, os, subprocess, sys, time + +STATE_DIR = os.getenv("IP_REHAB_STATE", "/opt/performancewest/data/ip_rehab") +START_FILE = os.path.join(STATE_DIR, "start_date") +SENT_FILE = os.path.join(STATE_DIR, "mailed.txt") # emails we've already rehab-mailed +TRANSPORTS = ["rehab02", "rehab03", "rehab04"] # .91 .92 .93 +# Each rehab transport is selected by an X-PW-Rehab-IP header (matched by a +# postfix header_checks FILTER), which OVERRIDES the transport_maps randmap +# warmup rotation so the message is actually bound to the rehab IP + throttle. +REHAB_HEADER = {"rehab02": "02", "rehab03": "03", "rehab04": "04"} +FROM_ADDR = "Performance West " +PHYS_ADDR = "Performance West Inc., 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001" +# We query listmonk via `docker exec psql` (no pg driver on host). +PG_CONTAINER = os.getenv("PG_CONTAINER", "performancewest-api-postgres-1") +PG_DB = os.getenv("LISTMONK_DB", "listmonk") + +# Consumer domains we never use for rehab (same policy as the warmup builder). +CONSUMER = { + "gmail.com", "googlemail.com", "yahoo.com", "yahoo.co.uk", "yahoo.ca", + "yahoo.es", "yahoo.it", "ymail.com", "rocketmail.com", "myyahoo.com", + "aol.com", "aim.com", "icloud.com", "me.com", "mac.com", + "hotmail.com", "hotmail.co.uk", "outlook.com", "live.com", "msn.com", + "comcast.net", "att.net", "sbcglobal.net", "bellsouth.net", "pacbell.net", + "verizon.net", "cox.net", "charter.net", "frontier.com", "frontiernet.net", + "windstream.net", "earthlink.net", "centurytel.net", "centurylink.net", +} + + +def ramp_day() -> int: + os.makedirs(STATE_DIR, exist_ok=True) + if not os.path.exists(START_FILE): + with open(START_FILE, "w") as f: + f.write(datetime.date.today().isoformat()) + return 0 + start = datetime.date.fromisoformat(open(START_FILE).read().strip()) + return (datetime.date.today() - start).days + + +def per_ip_quota(day: int) -> int: + # 10/IP day0, +10/day, cap 60/IP (=180/day total across 3 IPs at full ramp) + return min(10 + 10 * day, 60) + + +def already_mailed() -> set[str]: + if not os.path.exists(SENT_FILE): + return set() + return {l.strip().lower() for l in open(SENT_FILE) if l.strip()} + + +def pick_recipients(n: int, exclude: set[str]) -> list[tuple[str, str]]: + """Return up to n (email, name) for clean business-domain, never-bounced subs. + + Queried via `docker exec psql` since the host has no pg driver. + """ + consumer_list = "(" + ",".join("'%s'" % d for d in CONSUMER) + ")" + sql = ( + "SELECT s.email, COALESCE(s.name,'') " + "FROM subscribers s " + "WHERE s.status='enabled' " + f"AND lower(split_part(s.email,'@',2)) NOT IN {consumer_list} " + "AND NOT EXISTS (SELECT 1 FROM bounces b WHERE b.subscriber_id=s.id) " + f"ORDER BY random() LIMIT {n * 3}" + ) + try: + proc = subprocess.run( + ["docker", "exec", PG_CONTAINER, "psql", "-U", "pw", "-d", PG_DB, + "-At", "-F", "\t", "-c", sql], + capture_output=True, text=True, timeout=60, + ) + if proc.returncode != 0: + print(f"[ip-rehab] psql error: {proc.stderr.strip()[:200]}") + return [] + out: list[tuple[str, str]] = [] + for line in proc.stdout.splitlines(): + if "\t" not in line: + continue + email, _, name = line.partition("\t") + e = email.strip().lower() + if e and e not in exclude: + out.append((e, name.strip())) + if len(out) >= n: + break + return out + except Exception as exc: # noqa: BLE001 + print(f"[ip-rehab] recipient query failed: {exc}") + return [] + + +def build_email(to: str, name: str, transport: str) -> str: + first = (name.split(" ")[0] if name else "there") or "there" + unsub = f"https://performancewest.net/unsubscribe?e={to}" + return ( + f"X-PW-Rehab-IP: {REHAB_HEADER[transport]}\r\n" + f"From: {FROM_ADDR}\r\n" + f"To: {to}\r\n" + f"Subject: Quick compliance check-in from Performance West\r\n" + f"Content-Type: text/plain; charset=utf-8\r\n" + f"List-Unsubscribe: <{unsub}>\r\n" + f"\r\n" + f"Hi {first},\r\n\r\n" + f"This is a brief check-in from the compliance team at Performance West. " + f"If you handle FCC, DOT/FMCSA, healthcare, or corporate filings and want a " + f"quick read on upcoming deadlines for your business, just reply and we'll " + f"take a look -- no obligation.\r\n\r\n" + f"If this isn't useful, you can unsubscribe here: {unsub}\r\n\r\n" + f"{PHYS_ADDR}\r\n" + f"performancewest.net | (888) 411-0383\r\n" + ) + + +def send_via(transport: str, to: str, raw: str, dry: bool) -> bool: + if dry: + print(f" [dry] {transport} -> {to}") + return True + try: + # The X-PW-Rehab-IP header (in raw) triggers a header_checks FILTER that + # binds the message to this rehab transport + IP, overriding the randmap. + p = subprocess.run( + ["/usr/sbin/sendmail", "-f", "noreply@performancewest.net", to], + input=raw.encode(), timeout=60, + ) + return p.returncode == 0 + except Exception as exc: # noqa: BLE001 + print(f" send error {transport} -> {to}: {exc}") + return False + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--status", action="store_true") + args = ap.parse_args() + + day = ramp_day() + quota = per_ip_quota(day) + total = quota * len(TRANSPORTS) + + if args.status: + print(f"IP rehab: day={day} per_ip={quota} total_today={total}") + print(f"transports: {TRANSPORTS} (.91 .92 .93)") + print(f"already mailed: {len(already_mailed())}") + return 0 + + exclude = already_mailed() + recips = pick_recipients(total, exclude) + if not recips: + print("[ip-rehab] no clean recipients available") + return 0 + + print(f"[ip-rehab] day={day} per_ip={quota} total={len(recips)} " + f"({'DRY' if args.dry_run else 'SEND'})") + + sent = 0 + newly_mailed: list[str] = [] + for i, (email, name) in enumerate(recips): + transport = TRANSPORTS[i % len(TRANSPORTS)] + raw = build_email(email, name, transport) + if send_via(transport, email, raw, args.dry_run): + sent += 1 + newly_mailed.append(email) + time.sleep(0.2) + + if not args.dry_run and newly_mailed: + with open(SENT_FILE, "a") as f: + for e in newly_mailed: + f.write(e + "\n") + + print(f"[ip-rehab] done: sent={sent}/{len(recips)} via {len(TRANSPORTS)} IPs") + return 0 + + +if __name__ == "__main__": + sys.exit(main())