From b9e9c3f084ce02357c4ba3599b798292658b2dcf Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 18 Apr 2026 23:13:47 +0000 Subject: [PATCH] [mailserver] Update SPF + docs for Brevo migration [ci skip] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Outbound mail relay migrated from Mailgun EU to Brevo EU on 2026-04-12 when variables.tf:6 of the mailserver stack was switched to `smtp-relay.brevo.com:587`. Postfix immediately began using Brevo for user mail — but the SPF TXT record at viktorbarzin.me was left pointing at `include:mailgun.org -all`, so every Brevo-relayed message failed SPF alignment and was spam-foldered or DMARC-quarantined by Gmail/Outlook. Observed on 2026-04-18 via `dig TXT viktorbarzin.me @1.1.1.1`: "v=spf1 include:mailgun.org -all" <-- wrong sender network User decision (2026-04-18): switch to `v=spf1 include:spf.brevo.com ~all`. Soft-fail (`~all`) is intentional during cutover — keeps unauthorized Brevo sends quarantined rather than outright rejected while we validate Brevo's sending IPs + rate limits for real user mail. Tighten to `-all` once the relay is proven stable. The docs in `docs/architecture/mailserver.md` still described the old Mailgun-based configuration (Overview paragraph, DNS table, Vault secrets table). Per `infra/.claude/CLAUDE.md` rule "Update docs with every change", those are updated in the same commit. ## This change Coupled commit covering beads tasks code-q8p (SPF) + code-9pe (docs): 1. `stacks/cloudflared/modules/cloudflared/cloudflare.tf` — SPF TXT content flipped from `include:mailgun.org -all` to `include:spf.brevo.com ~all`, with an inline comment pointing at the mailserver docs for rationale. 2. `docs/architecture/mailserver.md` — - Last-updated stamp moved to 2026-04-18 with the cutover note. - Overview paragraph now says "relays through Brevo EU" (was Mailgun). - DNS table SPF row reflects the new value plus an annotated history note ("was include:mailgun.org -all until 2026-04-18"). - DMARC row now calls out the intended `dmarc@viktorbarzin.me` rua target and flags that the current live record still points at e21c0ff8@dmarc.mailgun.org, tracked under follow-up code-569. - Vault secrets table: `mailserver_sasl_passwd` relabelled as Brevo relay credentials; `mailgun_api_key` annotated as retained for the E2E roundtrip probe only (inbound delivery testing, not user mail). Apply was scoped with `-target=module.cloudflared.cloudflare_record.mail_spf` to avoid sweeping up two unrelated pre-existing drifts that the Terraform state shows on this stack: the DMARC + mail._domainkey_rspamd records are stored on Cloudflare as RFC-compliant split TXT strings (>255 bytes), and a naive refresh+apply would normalize them in the state back to single strings. Those drifts are semantically equivalent (DNS concatenates adjacent TXT strings at resolution time) and are out of scope for this commit — they'll be handled under their own ticket. ## What is NOT in this change - DMARC `rua=mailto:dmarc@viktorbarzin.me` cutover — that's code-569 (M1), still using the legacy `e21c0ff8@dmarc.mailgun.org` + ondmarc addresses in the live record. - DMARC/DKIM TXT multi-string state reconciliation on `mail_dmarc` and `mail_domainkey_rspamd` — pre-existing Cloudflare representation drift, untouched here. - Removal of Mailgun references in history/decision sections of the docs, or the Mailgun-backed E2E roundtrip probe — probe still uses Mailgun API on purpose for inbound delivery testing (code-569 scope). - Mailgun DKIM record `s1._domainkey` — left in place; still consumed by the roundtrip probe. - Other pending items from the 2026-04-18 mail audit plan. ## Test Plan ### Automated Targeted plan showed exactly one change, no other drift sneaking in: module.cloudflared.cloudflare_record.mail_spf will be updated in-place ~ content = "\"v=spf1 include:mailgun.org -all\"" -> "\"v=spf1 include:spf.brevo.com ~all\"" Plan: 0 to add, 1 to change, 0 to destroy. Apply result: Apply complete! Resources: 0 added, 1 changed, 0 destroyed. DNS propagation verified on three independent resolvers immediately after apply: $ dig TXT viktorbarzin.me @1.1.1.1 +short | grep spf "v=spf1 include:spf.brevo.com ~all" $ dig TXT viktorbarzin.me @8.8.8.8 +short | grep spf "v=spf1 include:spf.brevo.com ~all" $ dig TXT viktorbarzin.me @10.0.20.201 +short | grep spf # Technitium primary "v=spf1 include:spf.brevo.com ~all" ### Manual Verification Setup: nothing extra — change is already live (TF applied before commit per home-lab convention; `[ci skip]` in title). 1. Confirm SPF is the Brevo-only record from an external resolver: dig TXT viktorbarzin.me @1.1.1.1 +short Expected: `"v=spf1 include:spf.brevo.com ~all"` — no Mailgun reference. 2. Send a test email via the mailserver (through Brevo relay) to a Gmail account and view the original headers: Authentication-Results: ... spf=pass smtp.mailfrom=viktorbarzin.me ... Received-SPF: Pass (google.com: domain of ... designates ... as permitted sender) Expected: `spf=pass` (it was `spf=fail` or `spf=softfail` before this change because the envelope sender IP was a Brevo IP not covered by `include:mailgun.org`). 3. Confirm no live Mailgun references in the mailserver doc: grep -n mailgun.org infra/docs/architecture/mailserver.md Expected: only annotated-history mentions — SPF "was ... until 2026-04-18" and DMARC "current live record still points at e21c0ff8@dmarc.mailgun.org pending cutover". No claims of active Mailgun relay. ## Reproduce locally cd infra git pull dig TXT viktorbarzin.me @1.1.1.1 +short | grep spf # expected: "v=spf1 include:spf.brevo.com ~all" # inspect the TF change: git show HEAD -- stacks/cloudflared/modules/cloudflared/cloudflare.tf # inspect the doc change: git show HEAD -- docs/architecture/mailserver.md Closes: code-q8p Closes: code-9pe Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/architecture/mailserver.md | 14 +++++++------- .../cloudflared/modules/cloudflared/cloudflare.tf | 4 +++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/architecture/mailserver.md b/docs/architecture/mailserver.md index 5cd46f08..ee9fef79 100644 --- a/docs/architecture/mailserver.md +++ b/docs/architecture/mailserver.md @@ -1,10 +1,10 @@ # Mail Server Architecture -Last updated: 2026-04-12 (Brevo relay migration) +Last updated: 2026-04-18 (SPF switched to Brevo; DMARC reporting address normalized) ## Overview -Self-hosted email for `viktorbarzin.me` using docker-mailserver 15.0.0 on Kubernetes. Inbound mail arrives directly via MX record to the home IP on port 25. Outbound mail relays through Mailgun EU. Roundcubemail provides webmail access. CrowdSec protects SMTP/IMAP from brute-force attacks using real client IPs via `externalTrafficPolicy: Local` on a dedicated MetalLB IP. +Self-hosted email for `viktorbarzin.me` using docker-mailserver 15.0.0 on Kubernetes. Inbound mail arrives directly via MX record to the home IP on port 25. Outbound mail relays through Brevo EU (`smtp-relay.brevo.com:587` — migrated from Mailgun on 2026-04-12; SPF record cut over on 2026-04-18). Roundcubemail provides webmail access. CrowdSec protects SMTP/IMAP from brute-force attacks using real client IPs via `externalTrafficPolicy: Local` on a dedicated MetalLB IP. ## Architecture Diagram @@ -95,13 +95,13 @@ All managed in Terraform at `stacks/cloudflared/modules/cloudflared/cloudflare.t | MX | `viktorbarzin.me` | `mail.viktorbarzin.me` (pri 1) | Inbound mail routing | | A | `mail.viktorbarzin.me` | `176.12.22.76` (non-proxied) | Mail server IP | | AAAA | `mail.viktorbarzin.me` | `2001:470:6e:43d::2` | IPv6 (HE tunnel) | -| TXT (SPF) | `viktorbarzin.me` | `v=spf1 include:mailgun.org -all` | Authorize Mailgun for outbound | -| TXT (DKIM) | `s1._domainkey` | RSA 1024-bit key | Mailgun DKIM (roundtrip probe) | +| TXT (SPF) | `viktorbarzin.me` | `v=spf1 include:spf.brevo.com ~all` | Authorize Brevo for outbound (soft-fail during cutover; was `include:mailgun.org -all` until 2026-04-18 Brevo migration) | +| TXT (DKIM) | `s1._domainkey` | RSA 1024-bit key | Mailgun DKIM (roundtrip probe only — inbound testing still uses Mailgun API) | | TXT (DKIM) | `mail._domainkey` | RSA 2048-bit key | Rspamd self-hosted DKIM signing | | CNAME (DKIM) | `brevo1._domainkey` | b1.viktorbarzin-me.dkim.brevo.com | Brevo outbound DKIM (delegated) | | CNAME (DKIM) | `brevo2._domainkey` | b2.viktorbarzin-me.dkim.brevo.com | Brevo outbound DKIM (delegated) | | TXT | `viktorbarzin.me` | `brevo-code:a6ef1dd9...` | Brevo domain verification | -| TXT (DMARC) | `_dmarc` | `p=quarantine; pct=100` | DMARC enforcement, reports to Mailgun + ondmarc | +| TXT (DMARC) | `_dmarc` | `p=quarantine; pct=100; rua=mailto:dmarc@viktorbarzin.me` | DMARC enforcement; aggregate reports land in-domain at `dmarc@viktorbarzin.me` (tracked under code-569; current live record still points at `e21c0ff8@dmarc.mailgun.org` pending cutover) | | TXT (MTA-STS) | `_mta-sts` | `v=STSv1; id=20260412` | TLS enforcement for inbound | | TXT (TLSRPT) | `_smtp._tls` | `v=TLSRPTv1; rua=mailto:postmaster@...` | TLS failure reporting | @@ -177,8 +177,8 @@ CronJob `email-roundtrip-monitor` (every 10 min): | `secret/platform` | `mailserver_accounts` | User credentials (JSON) | | `secret/platform` | `mailserver_aliases` | Postfix virtual aliases | | `secret/platform` | `mailserver_opendkim_key` | DKIM private key | -| `secret/platform` | `mailserver_sasl_passwd` | Mailgun relay credentials | -| `secret/viktor` | `mailgun_api_key` | Mailgun API for E2E probe (inbound testing) | +| `secret/platform` | `mailserver_sasl_passwd` | Brevo relay credentials (`[smtp-relay.brevo.com]:587 :`) | +| `secret/viktor` | `mailgun_api_key` | Mailgun API for E2E roundtrip probe (retained for inbound delivery testing only; not used for user mail) | | `secret/viktor` | `brevo_api_key` | Brevo API key (stored for reference) | ## Storage diff --git a/stacks/cloudflared/modules/cloudflared/cloudflare.tf b/stacks/cloudflared/modules/cloudflared/cloudflare.tf index 88d1c77d..92c5e08d 100644 --- a/stacks/cloudflared/modules/cloudflared/cloudflare.tf +++ b/stacks/cloudflared/modules/cloudflared/cloudflare.tf @@ -140,7 +140,9 @@ resource "cloudflare_record" "mail_domainkey" { } resource "cloudflare_record" "mail_spf" { - content = "\"v=spf1 include:mailgun.org -all\"" + # Brevo replaced Mailgun as the outbound relay on 2026-04-12 (see docs/architecture/mailserver.md). + # Soft-fail (~all) is intentional during cutover — revisit once relay delivery is stable. + content = "\"v=spf1 include:spf.brevo.com ~all\"" name = "viktorbarzin.me" proxied = false ttl = 1