Skip to content

Commit d46803e

Browse files
baditaflorinclaude
andauthored
fix(mail): route all outbound mail through local Stalwart SMTP; add DKIM and DNS records (#17)
* [live-apply] Woodpecker CI deployed — 0.179.47 Bump platform_version and repo_version to 0.179.47. Update woodpecker live-apply receipt to reflect successful deployment of Woodpecker CI (ci.0mpc.com) with OpenBao secret injection, Gitea OAuth bootstrap, and seed repository activation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * [live-apply] DNS ledger and platform-service-watchdog deployed — 2026-05-26 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mail-gateway): use per-profile SMTP credentials for local Stalwart delivery The gateway's send_via_local_smtp was authenticating with a global LOCAL_SMTP_USERNAME/PASSWORD env var, but Stalwart's must-match-sender enforces that the authenticated user matches the FROM address. With three profiles each sending from their own mailbox (alerts@, platform@, agents@) this caused all local SMTP attempts to fail and fall through silently to Brevo. Changes: - app.py: load mailbox_password per profile in load_notification_profiles() - app.py: use profile.mailbox_localpart + profile.mailbox_password as SMTP credentials in send_via_local_smtp; fall back to global env vars if absent - app.py: disable SSL cert verification for local Stalwart (self-signed cert) - defaults/main.yml: flip gateway defaults to attempt_local_first=true, force_brevo_fallback=false (local SMTP is now the primary path) - defaults/main.yml: add mail_platform_dkim_* variables for DKIM key management - stalwart-config.toml.j2: add conditional DKIM signing block - tasks/main.yml: add task to deploy DKIM private key before Stalwart config render All three profiles (operator-alerts, platform-transactional, agent-reports) now route through local Stalwart SMTP and return channel=local_smtp on success. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mail-dns): switch from Brevo to local Stalwart DKIM; retire Brevo DNS records - inventory/host_vars/proxmox-host.yml: replace Brevo-specific DNS records with local DKIM (mail._domainkey TXT), fix SPF to "v=spf1 mx ~all" (remove Brevo include), mark brevo1/brevo2 CNAME and brevo-code TXT as absent for cleanup - defaults/main.yml: add mail_platform_dkim_public_key variable (reads from .local/mail-platform/dkim-public-key.txt at runtime) Running make converge-mail-platform now sets all mail DNS records for deliverability without Brevo dependency. The bootstrap playbook (playbooks/mail-platform.yml) handles the full DNS + runtime converge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mail-dns): add dkim_dns_value variable with proper 255-char chunking DNS TXT records are limited to 255 chars per string. The DKIM public key (392 chars) plus the prefix (18 chars) totals 410 chars. Add mail_platform_dkim_dns_value in role defaults that splits the value into two quoted strings per DNS convention: "v=DKIM1; k=rsa; p=<chunk1>" "<chunk2>" Use mail_platform_dkim_dns_value in the inventory mail._domainkey record so make converge-mail-platform is idempotent on future runs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b7e16b5 commit d46803e

5 files changed

Lines changed: 60 additions & 5 deletions

File tree

collections/ansible_collections/lv3/platform/roles/mail_platform_runtime/defaults/main.yml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,22 @@ mail_platform_brevo_sender_email: operator@example.com
6363
mail_platform_brevo_sender_name: Florin Mail Bridge
6464
mail_platform_reply_to_email: "{{ mail_platform_mailbox_address }}"
6565
mail_platform_gateway_bind_port: "{{ mail_platform_gateway_port }}"
66-
mail_platform_gateway_force_brevo_fallback: true
67-
mail_platform_gateway_attempt_local_first: false
66+
mail_platform_gateway_force_brevo_fallback: false
67+
mail_platform_gateway_attempt_local_first: true
68+
mail_platform_dkim_enabled: true
69+
mail_platform_dkim_selector: mail
70+
mail_platform_dkim_private_key_local_file: "{{ repo_shared_local_root }}/mail-platform/dkim-private.pem"
71+
mail_platform_dkim_private_key_host_path: "{{ mail_platform_stalwart_dir }}/etc/dkim-private.pem"
72+
mail_platform_dkim_private_key_container_path: /opt/stalwart/etc/dkim-private.pem
73+
# Public key (base64, no PEM headers) — read from .local at runtime.
74+
# lookup() returns '' when the file is absent so a missing key becomes a visible empty record.
75+
mail_platform_dkim_public_key: >-
76+
{{ lookup('ansible.builtin.file', repo_shared_local_root ~ '/mail-platform/dkim-public-key.txt', errors='ignore') | default('', true) | trim }}
77+
# DNS TXT record value split into ≤255-char chunks (DNS limit per string).
78+
# Produces: "v=DKIM1; k=rsa; p=<chunk1>" "<chunk2>"
79+
mail_platform_dkim_dns_value: >-
80+
{%- set full = 'v=DKIM1; k=rsa; p=' ~ mail_platform_dkim_public_key -%}
81+
"{{ full[:255] }}" "{{ full[255:] }}"
6882
mail_platform_gateway_uvicorn_workers: 1
6983
mail_platform_gateway_api_user: mail-platform
7084
mail_platform_notification_profiles:

collections/ansible_collections/lv3/platform/roles/mail_platform_runtime/files/mail-gateway/app.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def load_notification_profiles() -> dict[str, dict[str, Any]]:
7373
api_key = str(item.get("gateway_api_key", "")).strip()
7474
mailbox_address = str(item.get("mailbox_address", "")).strip()
7575
mailbox_localpart = str(item.get("mailbox_localpart", "")).strip()
76+
mailbox_password = str(item.get("mailbox_password", "")).strip()
7677
description = str(item.get("description", "")).strip()
7778
owner = str(item.get("owner", "")).strip()
7879
credential_scope = str(item.get("credential_scope", "")).strip()
@@ -106,6 +107,7 @@ def load_notification_profiles() -> dict[str, dict[str, Any]]:
106107
"id": profile_id,
107108
"mailbox_localpart": mailbox_localpart,
108109
"mailbox_address": mailbox_address,
110+
"mailbox_password": mailbox_password,
109111
"sender_email": sender_email,
110112
"sender_name": sender_name,
111113
"reply_to": reply_to,
@@ -373,10 +375,14 @@ def send_via_local_smtp(payload: SendRequest, profile: dict[str, Any]) -> None:
373375
message.set_content(payload.text or "")
374376

375377
context = ssl.create_default_context()
378+
context.check_hostname = False
379+
context.verify_mode = ssl.CERT_NONE # local Stalwart uses self-signed cert
376380
with smtplib.SMTP(LOCAL_SMTP_HOST, LOCAL_SMTP_PORT, timeout=20) as client:
377381
client.starttls(context=context)
378-
if LOCAL_SMTP_USERNAME and LOCAL_SMTP_PASSWORD:
379-
client.login(LOCAL_SMTP_USERNAME, LOCAL_SMTP_PASSWORD)
382+
smtp_username = profile.get("mailbox_localpart") or LOCAL_SMTP_USERNAME
383+
smtp_password = profile.get("mailbox_password") or LOCAL_SMTP_PASSWORD
384+
if smtp_username and smtp_password:
385+
client.login(smtp_username, smtp_password)
380386
client.send_message(message)
381387

382388

collections/ansible_collections/lv3/platform/roles/mail_platform_runtime/tasks/main.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,16 @@
383383
become: false
384384
no_log: true
385385

386+
- name: Deploy the DKIM private key for Stalwart outbound signing
387+
ansible.builtin.copy:
388+
src: "{{ mail_platform_dkim_private_key_local_file }}"
389+
dest: "{{ mail_platform_dkim_private_key_host_path }}"
390+
owner: root
391+
group: root
392+
mode: "0644"
393+
when: mail_platform_dkim_enabled | default(false)
394+
no_log: true
395+
386396
- name: Render the Stalwart configuration file
387397
ansible.builtin.template:
388398
src: stalwart-config.toml.j2

collections/ansible_collections/lv3/platform/roles/mail_platform_runtime/templates/stalwart-config.toml.j2

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,17 @@ events = {{ mail_platform_webhook_events | to_json }}
8080
timeout = "10s"
8181
throttle = "1s"
8282
lossy = false
83+
{% if mail_platform_dkim_enabled | default(false) %}
84+
85+
[signature."rsa-{{ mail_platform_dkim_selector }}"]
86+
private-key = "file://{{ mail_platform_dkim_private_key_container_path }}"
87+
algorithm = "rsa-sha256"
88+
domain = "{{ mail_platform_domain }}"
89+
selector = "{{ mail_platform_dkim_selector }}"
90+
headers.sign = ["From", "To", "Date", "Message-ID", "Subject", "MIME-Version", "Content-Type"]
91+
headers.oversign = ["From"]
92+
expiration = "7d"
93+
94+
[queue.outbound.signing]
95+
sign = [{ else = ["rsa-{{ mail_platform_dkim_selector }}"] }]
96+
{% endif %}

inventory/host_vars/proxmox-host.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1973,28 +1973,39 @@ mail_platform_dns_records:
19731973
ttl: 300
19741974
- name: "@"
19751975
type: TXT
1976-
value: '"v=spf1 mx include:spf.brevo.com -all"'
1976+
value: '"v=spf1 mx ~all"'
19771977
value_match_regex: '^"v=spf1 '
19781978
ttl: 300
19791979
- name: _dmarc
19801980
type: TXT
19811981
value: "\"v=DMARC1; p=quarantine; rua=mailto:{{ platform_operator_email }}\""
19821982
ttl: 300
1983+
- name: "mail._domainkey"
1984+
type: TXT
1985+
# DKIM key exceeds 255 chars per DNS string; split into two quoted chunks.
1986+
# mail_platform_dkim_dns_value is computed in role defaults from dkim-public-key.txt.
1987+
value: "{{ mail_platform_dkim_dns_value }}"
1988+
value_match_regex: '^"v=DKIM1; k=rsa; p='
1989+
ttl: 300
1990+
# Retire Brevo-specific records — mail is now delivered via local Stalwart SMTP
19831991
- name: brevo1._domainkey
19841992
type: CNAME
19851993
value: b1.lv3-org.dkim.brevo.com.
19861994
value_match_regex: '^b1\\.lv3-org\\.dkim\\.brevo\\.com\\.?$'
19871995
ttl: 300
1996+
state: absent
19881997
- name: brevo2._domainkey
19891998
type: CNAME
19901999
value: b2.lv3-org.dkim.brevo.com.
19912000
value_match_regex: '^b2\\.lv3-org\\.dkim\\.brevo\\.com\\.?$'
19922001
ttl: 300
2002+
state: absent
19932003
- name: "@"
19942004
type: TXT
19952005
value: '"brevo-code:b5e6a2e60000b8da96b912f8ea4c2e9a"'
19962006
value_match_regex: '^"brevo-code:'
19972007
ttl: 300
2008+
state: absent
19982009

19992010
route_dns_assertion_extra_records:
20002011
- name: "@"

0 commit comments

Comments
 (0)