email: add plaintext MIME part + stable Message-ID hostname

Two deliverability hardening fixes from the email audit:

1. Plaintext (altbody): all campaigns were HTML-only. Listmonk only emits
   multipart/alternative when altbody is set, and HTML-only bulk mail is a
   spam-score signal. New scripts/_email_plaintext.py renders a readable
   text/plain part from the HTML body (dependency-free; preserves Listmonk
   {{ .Subscriber }}/{{ UnsubscribeURL }} template tags, turns links into
   'text (url)'). Wired into the trucking builder (and thus UCR + IFTA, which
   reuse create_and_schedule_campaign) and the healthcare builder.

2. Stable container hostname: Listmonk derived its Message-ID from the random
   docker container id -> @localhost.localdomain (spam-score signal). Pin both
   listmonk + listmonk-hc hostname to perfwest.performancewest.net, matching
   Listmonk's SMTP hello_hostname.

Part of the email-deliverability incident hardening.
This commit is contained in:
justin 2026-06-17 20:09:02 -05:00
parent 2e4388a803
commit a32a3b05a0
4 changed files with 133 additions and 3 deletions

View file

@ -41,6 +41,7 @@ if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from scripts._email_exclusions import BLOCKED_EMAIL_DOMAINS
from scripts._email_plaintext import html_to_text
LOG = logging.getLogger("build_trucking_campaigns")
@ -551,6 +552,21 @@ def import_subscribers(list_id: int, subscribers: list[dict]) -> int:
return added
def _altbody_for(base: dict, body: str | None = None) -> str:
"""Plaintext (text/plain) part for a campaign.
Listmonk only emits multipart/alternative when altbody is set; HTML-only
mail is a spam-score signal. The source/base campaigns have no altbody, so
derive one from the HTML body. `body` overrides base["body"] for test sends
where merge fields were already substituted.
"""
existing = (base.get("altbody") or "").strip()
if existing:
return existing
html = body if body is not None else base.get("body", "")
return html_to_text(html)
def create_and_schedule_campaign(
base: dict,
list_id: int,
@ -566,7 +582,7 @@ def create_and_schedule_campaign(
"type": "regular",
"content_type": base["content_type"],
"body": base["body"],
"altbody": base.get("altbody"),
"altbody": _altbody_for(base),
"template_id": base["template_id"],
"tags": base.get("tags") or [],
"messenger": base.get("messenger") or "email",
@ -611,7 +627,7 @@ def send_test(base: dict, campaign_id: int, sample_row: tuple, label: str, tz: s
"name": base.get("name", "Test"), "subject": subj,
"lists": list_ids, "from_email": base["from_email"],
"type": "regular", "content_type": base["content_type"],
"body": body, "altbody": base.get("altbody"),
"body": body, "altbody": _altbody_for(base, body),
"template_id": base["template_id"],
"tags": base.get("tags") or [], "messenger": base.get("messenger") or "email",
"headers": base.get("headers") or REPLY_TO_HEADERS,