docs+infra(deliverability): document bulk subdomain; ansible signs send.performancewest.net

- infra/ansible/roles/mail: refactor OpenDKIM to support multiple signing domains
  via opendkim_signing_domains list (root + send.performancewest.net). Loops
  keygen/ownership/keytable/signingtable so the live two-domain setup is
  reproducible from ansible.
- infra/ansible group_vars: add bulk_mail_subdomain + campaign_from_* +
  campaign_reply_to documentation vars (map to CAMPAIGN_FROM / HC_CAMPAIGN_FROM
  env read by the builder scripts). smtp_from (transactional) stays on root.
- docs/deliverability.md: rewrite TL;DR with the carrierone-vs-performancewest
  A/B proof (same server/IPs, different From domain -> Inbox vs Junk) and the
  ~85% Microsoft / 14% Google / <1% Yahoo audience mix; add the bulk-subdomain
  section, SPF trim, rehab-disabled, and the Hestia DNS automation runbook.
This commit is contained in:
justin 2026-06-18 23:12:05 -05:00
parent 5c3b4291e7
commit 3ca960aca5
4 changed files with 158 additions and 29 deletions

View file

@ -80,6 +80,21 @@ smtp_pass: "{{ vault_smtp_pass }}"
smtp_from: "Performance West <noreply@performancewest.net>"
smtp_admin_email: ops@performancewest.net
# ── Bulk campaign From (Listmonk) ────────────────────────────────────────────
# Cold/bulk campaign mail is sent From a dedicated bulk subdomain so its sending
# reputation is ISOLATED from the root domain. The root domain (smtp_from above)
# carries transactional/verification/receipt mail and stays clean. Replies still
# route to the root domain via Reply-To, so the customer reply experience is
# unchanged. These map to the CAMPAIGN_FROM / HC_CAMPAIGN_FROM env vars read by
# scripts/build_trucking_campaigns.py and build_healthcare_campaigns_cron.py.
# See docs/deliverability.md. The subdomain's DNS (A/MX/SPF/DKIM selector=send/
# DMARC) is published on the Hestia DNS master; OpenDKIM signs it (see role mail,
# opendkim_signing_domains).
bulk_mail_subdomain: send.performancewest.net
campaign_from_trucking: "Performance West <noreply@send.performancewest.net>"
campaign_from_healthcare: "Performance West Compliance <compliance@send.performancewest.net>"
campaign_reply_to: info@performancewest.net
# ── Listmonk (mass-mail via the LOCAL MTA) ───────────────────────────────────
# Listmonk SMTP is configured via its web admin UI, not env vars. Listmonk relays
# through the host Postfix (172.18.0.1:25 from inside the Docker network), which

View file

@ -13,6 +13,19 @@ opendkim_selector: mail
opendkim_signing_domain: performancewest.net
opendkim_socket: "inet:8891@localhost"
# Signing domains. The root domain carries transactional/verification mail; the
# dedicated bulk subdomain (send.performancewest.net) carries Listmonk campaign
# mail so its sending reputation is isolated from the root domain (which then
# stays clean and recovers faster). Each entry generates its own key + selector
# and contributes a line to KeyTable/SigningTable. The first entry is treated as
# the primary (kept for backwards-compat with opendkim_signing_domain above).
# See docs/deliverability.md.
opendkim_signing_domains:
- domain: "{{ opendkim_signing_domain }}"
selector: "{{ opendkim_selector }}"
- domain: "send.performancewest.net"
selector: "send"
# Hosts OpenDKIM will SIGN for (vs verify). Must include the Docker bridge
# subnet so Listmonk container traffic is signed.
opendkim_internal_hosts:

View file

@ -8,43 +8,57 @@
- name: Ensure OpenDKIM key directory exists
ansible.builtin.file:
path: "/etc/opendkim/keys/{{ opendkim_signing_domain }}"
path: "/etc/opendkim/keys/{{ item.domain }}"
state: directory
owner: opendkim
group: opendkim
mode: "0750"
loop: "{{ opendkim_signing_domains }}"
loop_control:
label: "{{ item.domain }}"
- name: Generate DKIM keypair if missing
ansible.builtin.command:
cmd: >-
opendkim-genkey
-b 2048
-d {{ opendkim_signing_domain }}
-s {{ opendkim_selector }}
-D /etc/opendkim/keys/{{ opendkim_signing_domain }}
creates: "/etc/opendkim/keys/{{ opendkim_signing_domain }}/{{ opendkim_selector }}.private"
-d {{ item.domain }}
-s {{ item.selector }}
-D /etc/opendkim/keys/{{ item.domain }}
creates: "/etc/opendkim/keys/{{ item.domain }}/{{ item.selector }}.private"
loop: "{{ opendkim_signing_domains }}"
loop_control:
label: "{{ item.domain }} ({{ item.selector }})"
register: dkim_keygen
- name: Fix DKIM private key ownership
ansible.builtin.file:
path: "/etc/opendkim/keys/{{ opendkim_signing_domain }}/{{ opendkim_selector }}.private"
path: "/etc/opendkim/keys/{{ item.domain }}/{{ item.selector }}.private"
owner: opendkim
group: opendkim
mode: "0600"
loop: "{{ opendkim_signing_domains }}"
loop_control:
label: "{{ item.domain }}"
- name: Show DKIM public DNS record to publish (only when newly generated)
- name: Show DKIM public DNS records to publish (only when newly generated)
ansible.builtin.debug:
msg: >-
A new DKIM key was generated. Publish the TXT record from
/etc/opendkim/keys/{{ opendkim_signing_domain }}/{{ opendkim_selector }}.txt
at {{ opendkim_selector }}._domainkey.{{ opendkim_signing_domain }}
when: dkim_keygen is changed
/etc/opendkim/keys/{{ item.item.domain }}/{{ item.item.selector }}.txt
at {{ item.item.selector }}._domainkey.{{ item.item.domain }}
loop: "{{ dkim_keygen.results }}"
loop_control:
label: "{{ item.item.domain }}"
when: item is changed
- name: Deploy OpenDKIM KeyTable
ansible.builtin.copy:
dest: /etc/opendkim/key.table
content: |
{{ opendkim_selector }}._domainkey.{{ opendkim_signing_domain }} {{ opendkim_signing_domain }}:{{ opendkim_selector }}:/etc/opendkim/keys/{{ opendkim_signing_domain }}/{{ opendkim_selector }}.private
{% for d in opendkim_signing_domains %}
{{ d.selector }}._domainkey.{{ d.domain }} {{ d.domain }}:{{ d.selector }}:/etc/opendkim/keys/{{ d.domain }}/{{ d.selector }}.private
{% endfor %}
owner: root
group: root
mode: "0644"
@ -54,7 +68,9 @@
ansible.builtin.copy:
dest: /etc/opendkim/signing.table
content: |
*@{{ opendkim_signing_domain }} {{ opendkim_selector }}._domainkey.{{ opendkim_signing_domain }}
{% for d in opendkim_signing_domains %}
*@{{ d.domain }} {{ d.selector }}._domainkey.{{ d.domain }}
{% endfor %}
owner: root
group: root
mode: "0644"