new-site/infra/ansible/roles/mail/tasks/main.yml
justin 3ca960aca5 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.
2026-06-18 23:12:05 -05:00

141 lines
4 KiB
YAML

---
- name: Install OpenDKIM + tools
ansible.builtin.apt:
name:
- opendkim
- opendkim-tools
state: present
- name: Ensure OpenDKIM key directory exists
ansible.builtin.file:
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 {{ 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/{{ 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 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/{{ 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: |
{% 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"
notify: Restart opendkim
- name: Deploy OpenDKIM SigningTable
ansible.builtin.copy:
dest: /etc/opendkim/signing.table
content: |
{% for d in opendkim_signing_domains %}
*@{{ d.domain }} {{ d.selector }}._domainkey.{{ d.domain }}
{% endfor %}
owner: root
group: root
mode: "0644"
notify: Restart opendkim
- name: Deploy OpenDKIM trusted/internal hosts (MUST include Docker subnet)
ansible.builtin.template:
src: trusted.hosts.j2
dest: /etc/opendkim/trusted.hosts
owner: root
group: root
mode: "0644"
notify: Restart opendkim
- name: Deploy opendkim.conf (table signing + InternalHosts)
ansible.builtin.template:
src: opendkim.conf.j2
dest: /etc/opendkim.conf
owner: root
group: root
mode: "0644"
validate: "opendkim -n -f -x %s"
notify: Restart opendkim
- name: Ensure OpenDKIM is enabled and running
ansible.builtin.systemd:
name: opendkim
enabled: true
state: started
- name: Wire Postfix to the OpenDKIM milter
ansible.builtin.command:
cmd: "postconf -e {{ item }}"
loop:
- "smtpd_milters={{ opendkim_socket }}"
- "non_smtpd_milters={{ opendkim_socket }}"
- "milter_default_action=accept"
- "milter_protocol=6"
register: postfix_milter
changed_when: false
notify: Reload postfix
# Postfix on this host logs via its built-in postlogd (maillog_file mode), not
# rsyslog -- there is no rsyslog.service. postlogd holds mail.log open, so a
# plain rename+create leaves it writing to the old inode. Use copytruncate
# (copy then truncate in place) which needs no daemon signal. mail.log had
# grown unbounded to ~1 GB (~150 MB/day) with no rotation rule at all.
- name: Install logrotate rule for Postfix (postlogd) mail logs
ansible.builtin.copy:
dest: /etc/logrotate.d/rsyslog-mail
owner: root
group: root
mode: "0644"
content: |
/var/log/mail.log
/var/log/mail.err
/var/log/mail.warn
/var/log/mail.info
{
rotate 14
daily
missingok
notifempty
compress
delaycompress
copytruncate
}