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.
This commit is contained in:
justin 2026-06-09 20:27:47 -05:00
parent 6f361d307d
commit 25f4a7503b
2 changed files with 207 additions and 0 deletions

8
infra/cron/pw-ip-rehab Normal file
View file

@ -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

199
scripts/ip_rehab.py Normal file
View file

@ -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 <noreply@performancewest.net>"
PHYS_ADDR = "Performance West Inc., 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001"
# We query listmonk via `docker exec <pg_container> 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 <pg> 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())