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:
parent
6f361d307d
commit
25f4a7503b
2 changed files with 207 additions and 0 deletions
8
infra/cron/pw-ip-rehab
Normal file
8
infra/cron/pw-ip-rehab
Normal 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
199
scripts/ip_rehab.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue