fix(email): correct Reply-To header shape for listmonk (was silently dropped)

Listmonk applies campaign headers as `for hdr,val := range set { h.Add(hdr,val) }`
(internal/manager/manager.go v6.1.0): each map's KEY is the literal header name.
The trucking/CRTC/deficiency builders wrote {"name":"Reply-To","value":..} (and
{"key":..,"value":..}), which emits junk `name:`/`value:` headers and NO real
Reply-To, so replies fell back to the From address (noreply@send.performancewest.net)
instead of info@performancewest.net. HC builder already used the correct
{"Reply-To": value} shape; match it everywhere. Verified against listmonk source.

Impact: outbound only; no customer replies were lost (noreply@ is a real mailbox),
but reply UX pointed at a no-reply address. Live campaign headers re-patched separately.
This commit is contained in:
justin 2026-06-21 01:03:07 -05:00
parent 297db74fee
commit e414ec4a5f
3 changed files with 10 additions and 4 deletions

View file

@ -554,7 +554,13 @@ TIMEZONE_CONFIG = {
# Owner email — test sends go here before each campaign is scheduled # Owner email — test sends go here before each campaign is scheduled
TEST_EMAIL = os.getenv("CAMPAIGN_TEST_EMAIL", "carrierone@gmx.com") TEST_EMAIL = os.getenv("CAMPAIGN_TEST_EMAIL", "carrierone@gmx.com")
REPLY_TO_EMAIL = os.getenv("CAMPAIGN_REPLY_TO", "info@performancewest.net") REPLY_TO_EMAIL = os.getenv("CAMPAIGN_REPLY_TO", "info@performancewest.net")
REPLY_TO_HEADERS = [{"name": "Reply-To", "value": REPLY_TO_EMAIL}] # Listmonk applies campaign headers as `for hdr, val := range set { h.Add(hdr, val) }`
# (internal/manager/manager.go), i.e. each map's KEY is the literal header name.
# So the correct shape is {"Reply-To": value}; a {"name": ..., "value": ...} map
# would emit junk "name:"/"value:" headers and NO real Reply-To, silently sending
# replies to the From address (noreply@send.performancewest.net) instead. The
# healthcare builder already uses the correct shape; match it here.
REPLY_TO_HEADERS = [{"Reply-To": REPLY_TO_EMAIL}]
# Bulk From — sends from the dedicated bulk subdomain so its sending reputation # Bulk From — sends from the dedicated bulk subdomain so its sending reputation
# is isolated from the root domain (which stays clean for transactional / # is isolated from the root domain (which stays clean for transactional /

View file

@ -307,7 +307,7 @@ def update_existing_campaign(campaign_id: int, cfg: dict, body: str, dry: bool)
"template_id": existing.get("template_id") or TEMPLATE_ID, "template_id": existing.get("template_id") or TEMPLATE_ID,
"tags": existing.get("tags") or ["trucking", "deficiency", "source"], "tags": existing.get("tags") or ["trucking", "deficiency", "source"],
"messenger": existing.get("messenger") or "email", "messenger": existing.get("messenger") or "email",
"headers": existing.get("headers") or [{"name": "Reply-To", "value": REPLY_TO}], "headers": existing.get("headers") or [{"Reply-To": REPLY_TO}],
} }
if dry: if dry:
print(f" [{cfg['env']}] DRY-RUN would update source campaign {campaign_id} (body {len(body)} chars)") print(f" [{cfg['env']}] DRY-RUN would update source campaign {campaign_id} (body {len(body)} chars)")
@ -340,7 +340,7 @@ def create_draft(seg_key: str, cfg: dict, dry: bool, update_existing: bool = Fal
"template_id": TEMPLATE_ID, "template_id": TEMPLATE_ID,
"tags": ["trucking", "deficiency", "source"], "tags": ["trucking", "deficiency", "source"],
"messenger": "email", "messenger": "email",
"headers": [{"name": "Reply-To", "value": REPLY_TO}], "headers": [{"Reply-To": REPLY_TO}],
} }
res = b.lm_api("/campaigns", payload, "POST") res = b.lm_api("/campaigns", payload, "POST")
cid = res["data"]["id"] cid = res["data"]["id"]

View file

@ -251,7 +251,7 @@ def create_campaign():
"content_type": "html", "content_type": "html",
"body": campaign_html, "body": campaign_html,
"status": "draft", "status": "draft",
"headers": [{"key": "Reply-To", "value": "info@performancewest.net"}], "headers": [{"Reply-To": "info@performancewest.net"}],
}, timeout=30) }, timeout=30)
if not r.ok: if not r.ok: