fix(hc-cron): stop re-mailing the whole list daily (per-day send lists)

The HC warmup imported ~1000 fresh providers/day into a persistent segment list
(list 10), but each day's campaign targeted that WHOLE cumulative list -- so the
cohort imported on day 1 received the identical 'Free NPI Check' email every
subsequent day (verified: subscriber 3410 got campaigns 38-42, 5x). Program-wide
that was 23,843 sends to 6,587 people (3.6x avg, max 30x) and blocklisted ~12%
of the list -- a Yahoo 'user complaints' deferral now confirms the burn.

Fix: import each day's slice into a dedicated dated SEND list and bind that day's
campaign to ONLY that list, so every provider gets exactly one send. The
persistent segment list is still used for dedup/records. ensure_campaign now
matches the exact dated name (never reuses a prior day's campaign/slice).
This commit is contained in:
justin 2026-06-26 12:19:41 -05:00
parent 4276adab80
commit b350a1367d

View file

@ -301,20 +301,23 @@ def add_subscriber(list_id: int, email: str, name: str, attribs: dict) -> bool:
return False return False
def ensure_campaign(seg_key: str, list_id: int) -> int: def ensure_campaign(seg_key: str, list_id: int, send_list_name: str | None = None) -> int:
# Reuse an existing warmup campaign for this segment only if it's still # One campaign per (segment, day): each day's campaign is bound to that day's
# ACTIVE (draft / running / paused / scheduled). A finished/cancelled one # dedicated SEND list, so it mails ONLY that day's newly-imported slice. We
# can't accept new subscribers or restart, so we create a fresh dated one -- # therefore reuse a campaign only when it's the SAME day's campaign (matched by
# that also picks up the latest email template (the canonical hc_*.html). # the dated name) and still active; we never reuse a prior day's campaign,
# because that would re-target an old slice. A finished/cancelled same-day
# campaign can't restart, so a fresh one is created (also picking up the latest
# canonical hc_*.html template).
from datetime import date from datetime import date
seg = SEGMENTS[seg_key] seg = SEGMENTS[seg_key]
ACTIVE = {"draft", "running", "paused", "scheduled"} ACTIVE = {"draft", "running", "paused", "scheduled"}
dated = f"{seg['campaign_name']} - {date.today():%b %d %Y}"
res = lm("/campaigns?per_page=100") res = lm("/campaigns?per_page=100")
for c in res.get("data", {}).get("results", []): for c in res.get("data", {}).get("results", []):
if c["name"].startswith(seg["campaign_name"]) and c.get("status") in ACTIVE: if c["name"] == dated and c.get("status") in ACTIVE:
return c["id"] return c["id"]
body = open(template_path(seg_key)).read() body = open(template_path(seg_key)).read()
dated = f"{seg['campaign_name']} - {date.today():%b %d %Y}"
payload = { payload = {
"name": dated, "subject": seg["subject"], "lists": [list_id], "name": dated, "subject": seg["subject"], "lists": [list_id],
"from_email": FROM_EMAIL, "type": "regular", "content_type": "richtext", "from_email": FROM_EMAIL, "type": "regular", "content_type": "richtext",
@ -552,13 +555,29 @@ def warm_segment(seg_key: str, rows: list[dict], slice_n: int,
return 0 return 0
list_id = get_or_create_list(seg["list_name"], ["healthcare", "warmup", seg_key]) list_id = get_or_create_list(seg["list_name"], ["healthcare", "warmup", seg_key])
# Per-day SEND list: the campaign targets ONLY today's newly-imported slice,
# never the whole accumulated segment list. Without this, each day's campaign
# re-mailed every prior subscriber (the cumulative list), so the cohort
# imported on day 1 received the identical email every subsequent day -- a
# 5-6x repeat that burned reputation and blocklisted ~12% of the list. The
# persistent `list_id` is still used for dedup/records; the dated send list is
# what the campaign actually sends to, so every provider gets exactly one send.
from datetime import date
send_list_name = f"{seg['list_name']} - SEND {date.today():%Y-%m-%d}"
send_list_id = get_or_create_list(send_list_name,
["healthcare", "warmup", seg_key, "daily-send"])
n_ok = 0 n_ok = 0
for r in todo: for r in todo:
email = r["email"].strip().lower() email = r["email"].strip().lower()
if add_subscriber(list_id, email, r.get("name") or "", attribs_for(r)): # Add to BOTH the persistent segment list (dedup/records) and today's
# send list (the campaign's only audience).
ok = add_subscriber(list_id, email, r.get("name") or "", attribs_for(r))
add_subscriber(send_list_id, email, r.get("name") or "", attribs_for(r))
if ok:
imported.add(email); n_ok += 1 imported.add(email); n_ok += 1
save_imported(seg_key, imported) save_imported(seg_key, imported)
cid = ensure_campaign(seg_key, list_id) # Campaign targets the per-day send list, NOT the cumulative segment list.
cid = ensure_campaign(seg_key, send_list_id, send_list_name)
if start_campaign: if start_campaign:
try: try:
lm(f"/campaigns/{cid}/status", {"status": "running"}, "PUT") lm(f"/campaigns/{cid}/status", {"status": "running"}, "PUT")
@ -566,7 +585,8 @@ def warm_segment(seg_key: str, rows: list[dict], slice_n: int,
# Already running raises; that's fine. # Already running raises; that's fine.
if "already" not in str(e).lower(): if "already" not in str(e).lower():
print(f"[hc-cron] {seg_key}: start warning: {e}") print(f"[hc-cron] {seg_key}: start warning: {e}")
print(f"[hc-cron] {seg_key}: imported {n_ok} into list {list_id}; campaign={cid}") print(f"[hc-cron] {seg_key}: imported {n_ok} into list {list_id} "
f"(send list {send_list_id}); campaign={cid}")
return n_ok return n_ok