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:
parent
5c3b4291e7
commit
3ca960aca5
4 changed files with 158 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue