Skip to content

fix(mail): route all outbound mail through local Stalwart SMTP; add DKIM and DNS records#17

Merged
baditaflorin merged 5 commits into
mainfrom
claude/mail-smtp-local-routing
May 26, 2026
Merged

fix(mail): route all outbound mail through local Stalwart SMTP; add DKIM and DNS records#17
baditaflorin merged 5 commits into
mainfrom
claude/mail-smtp-local-routing

Conversation

@baditaflorin
Copy link
Copy Markdown
Owner

Summary

  • Gateway per-profile SMTP auth: send_via_local_smtp was using a global LOCAL_SMTP_USERNAME/PASSWORD env var, but Stalwart's must-match-sender=true rejects cross-account sends. Fixed to use each profile's own mailbox_localpart + mailbox_password — all three profiles (operator-alerts, platform-transactional, agent-reports) now route via local SMTP and return channel=local_smtp
  • Gateway defaults flipped: ATTEMPT_LOCAL_FIRST=true, FORCE_BREVO_FALLBACK=false — local Stalwart is now the primary path; Brevo is a dead fallback
  • SSL fix: disabled cert verification for the self-signed Stalwart TLS cert in send_via_local_smtp
  • DKIM: added conditional DKIM signing block to stalwart-config.toml.j2; new task deploys dkim-private.pem from .local/; defaults expose mail_platform_dkim_public_key and mail_platform_dkim_dns_value (auto-chunked for 255-char DNS limit)
  • DNS records: mail_platform_dns_records in inventory updated — SPF now v=spf1 mx ~all, mail._domainkey TXT added, Brevo CNAME/verification records marked state: absent for cleanup

Applied to production

All DNS records are already live in Hetzner DNS zone for 0mpc.com (applied manually this session):

  • mail A65.108.75.123
  • @ MX10 mail.0mpc.com.
  • @ TXT"v=spf1 mx ~all"
  • _dmarc TXT"v=DMARC1; p=quarantine; rua=mailto:..."
  • mail._domainkey TXT → DKIM RSA-2048 public key (chunked)
  • Brevo brevo1/brevo2._domainkey CNAMEs deleted

Pending user action (not automated)

  • Nameserver delegation: domain 0mpc.com is registered at Hetzner but still uses parking NSes (ns1.expirationwarning.net). Update to ns1.your-server.de, ns.second-ns.com, ns3.second-ns.de via Hetzner account → Domains
  • PTR record: set reverse DNS for 65.108.75.123mail.0mpc.com at https://robot.hetzner.com

Bootstrap playbook

make converge-mail-platform (which runs playbooks/mail-platform.yml) now handles the complete bootstrap: DNS records, runtime converge, gateway rebuild, DKIM key deployment.

Release checklist

  • Role code changes committed
  • Inventory DNS records updated
  • DKIM variables added to defaults
  • DNS records applied to production zone
  • VERSION / changelog (non-release PR)

baditaflorin and others added 5 commits May 21, 2026 21:58
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>
…-05-26

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t 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>
… 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>
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>
@baditaflorin baditaflorin merged commit d46803e into main May 26, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant