feat(deliverability): send bulk campaigns from dedicated subdomain send.performancewest.net

Isolates bulk sending reputation onto a dedicated subdomain so the root domain
stays clean for transactional/verification mail (and recovers faster). Replies
still go to the root domain via Reply-To, so the customer-facing reply experience
is unchanged.

- build_trucking_campaigns.py: add env-overridable FROM_EMAIL
  (noreply@send.performancewest.net); use it for both scheduled + test sends
  instead of inheriting base["from_email"] from the DB base campaign.
- build_healthcare_campaigns_cron.py: FROM_EMAIL ->
  compliance@send.performancewest.net (env-overridable).
- bounce-watcher.sh / hc-bounce-watcher.sh: track the new subdomain envelope
  sender (keep legacy root-domain sender so the pre-cutover queue still drains;
  HC also tracks by hcout transport regardless of sender).

Infra already live (separate, non-code): subdomain DNS (A/MX/SPF/DKIM
selector=send/DMARC p=reject) on the Hestia master, OpenDKIM signs
d=send.performancewest.net (verified end-to-end), egress .94/.107. Root SPF
trimmed to the real IPs; pointless IP-rehab cron disabled.
This commit is contained in:
justin 2026-06-18 23:07:23 -05:00
parent 1056705cf9
commit 5c3b4291e7
4 changed files with 28 additions and 9 deletions

View file

@ -23,8 +23,10 @@ CURRENT_UUID=""
tail -F "$LOG" 2>/dev/null | while IFS= read -r line; do
# Track queue IDs originating from campaign sender
if echo "$line" | grep -q "from=<noreply@performancewest.net>"; then
# Track queue IDs originating from campaign sender. Bulk now sends from the
# dedicated bulk subdomain (noreply@send.performancewest.net); the root-domain
# sender is kept so the pre-cutover queue still drains correctly.
if echo "$line" | grep -qE "from=<noreply@send.performancewest.net>|from=<noreply@performancewest.net>"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
if [ -n "$QID" ]; then
CAMPAIGN_QIDS[$QID]=1

View file

@ -54,8 +54,15 @@ WARMUP_STAMP = "/etc/postfix/hc-warmup-start"
# re-mails them, regardless of segment or stale CMS data. Append + run --prune.
SUPPRESS_FILE = os.getenv("HC_SUPPRESS_FILE", os.path.join(STATE_DIR, "hc_suppress.txt"))
FROM_EMAIL = "Performance West Compliance <compliance@performancewest.net>"
REPLY_TO = "info@performancewest.net"
# Bulk From — sends from the dedicated bulk subdomain so its sending reputation
# is isolated from the root domain (which stays clean for transactional /
# verification mail). Replies still go to the root domain via Reply-To, so the
# customer-facing reply experience is unchanged. See docs/deliverability.md.
FROM_EMAIL = os.getenv(
"HC_CAMPAIGN_FROM",
"Performance West Compliance <compliance@send.performancewest.net>",
)
REPLY_TO = os.getenv("HC_CAMPAIGN_REPLY_TO", "info@performancewest.net")
# Segment registry (subject, template file, list/campaign names, row selector)
# is the single source of truth shared with build_healthcare_campaigns.py.

View file

@ -342,6 +342,12 @@ TEST_EMAIL = os.getenv("CAMPAIGN_TEST_EMAIL", "carrierone@gmx.com")
REPLY_TO_EMAIL = os.getenv("CAMPAIGN_REPLY_TO", "info@performancewest.net")
REPLY_TO_HEADERS = [{"name": "Reply-To", "value": REPLY_TO_EMAIL}]
# Bulk From — sends from the dedicated bulk subdomain so its sending reputation
# is isolated from the root domain (which stays clean for transactional /
# verification mail). Replies still go to the root domain via Reply-To above, so
# the customer-facing reply experience is unchanged. See docs/deliverability.md.
FROM_EMAIL = os.getenv("CAMPAIGN_FROM", "Performance West <noreply@send.performancewest.net>")
# Which verification results are safe to SEND to. We key ONLY off
# email_verify_result, never the email_verified boolean: the verifier sets
# email_verified=TRUE optimistically for 'mx_unreachable' (domain exists but its
@ -729,7 +735,7 @@ def create_and_schedule_campaign(
"name": name,
"subject": base["subject"],
"lists": [list_id],
"from_email": base["from_email"],
"from_email": FROM_EMAIL,
"type": "regular",
"content_type": base["content_type"],
"body": base["body"],
@ -776,7 +782,7 @@ def send_test(base: dict, campaign_id: int, sample_row: tuple, label: str, tz: s
list_ids = [l["id"] for l in base.get("lists", []) if isinstance(l, dict)] or [1]
payload = {
"name": base.get("name", "Test"), "subject": subj,
"lists": list_ids, "from_email": base["from_email"],
"lists": list_ids, "from_email": FROM_EMAIL,
"type": "regular", "content_type": base["content_type"],
"body": body, "altbody": _altbody_for(base, body),
"template_id": base["template_id"],

View file

@ -17,8 +17,12 @@ TOKEN_FILE=/opt/performancewest/.secrets/hc-listmonk-token
TOKEN="$(cat "$TOKEN_FILE" 2>/dev/null)"
AUTHHDR="Authorization: token api:${TOKEN}"
# hc campaign senders
HC_SENDER1="from=<compliance@performancewest.net>"
# hc campaign senders. Bulk now sends from the dedicated bulk subdomain
# (compliance@send.performancewest.net); the root-domain sender is kept so the
# pre-cutover queue still drains correctly. (Transport-based tracking below also
# catches everything that egresses an hcout transport, regardless of sender.)
HC_SENDER1="from=<compliance@send.performancewest.net>"
HC_SENDER_LEGACY="from=<compliance@performancewest.net>"
get_campaign_uuid() {
curl -s -H "$AUTHHDR" "$API/api/campaigns?status=running&per_page=1" 2>/dev/null \
@ -43,7 +47,7 @@ post_bounce() {
tail -F "$LOG" 2>/dev/null | while IFS= read -r line; do
# Track queue IDs that originate from the hc sender OR go out an hcout transport.
if echo "$line" | grep -q "$HC_SENDER1"; then
if echo "$line" | grep -qE "$HC_SENDER1|$HC_SENDER_LEGACY"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
[ -n "$QID" ] && HC_QIDS[$QID]=1
fi