Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
SMTP relay (Postfix + sasl-xoauth2 → Microsoft 365)
One internal SMTP endpoint that in-cluster apps use for outbound mail. It authenticates
to Exchange Online with OAuth2 (XOAUTH2) via mauroreggio/postfix-365, which bundles
sasl-xoauth2 — tokens are refreshed inside the SASL layer, no sidecar/cron. Basic-auth
SMTP (app passwords) is being retired by Microsoft; this is the modern replacement.
Authelia / Gitea / … ──plain SMTP :25 (in-cluster, no auth)──▶ smtp-relay ──587 STARTTLS + XOAUTH2──▶ smtp.office365.com
Reach it at: smtp-relay.smtp-relay.svc.cluster.local:25. Sends as [email protected].
Prerequisites (Microsoft 365 / Entra — mostly interactive, one-time)
ddupan.topis a verified domain in the tenant (Admin center → Settings → Domains, Status = Healthy). If not, add it and complete the TXT verification (DNS is Cloudflare — can be done in../../../infrastructure/cloudflared/terraform/).- Mailbox
[email protected]exists with an Exchange Online license. - Authenticated SMTP enabled on it: Admin center → Users → that user → Mail → Manage email apps → tick Authenticated SMTP. (OAuth won't work without this.)
Step 1 — Entra app registration (as code)
cd terraform
az login # account that can create app regs AND grant admin consent (Global Admin)
terraform init
terraform apply # creates the app, delegated Graph SMTP.Send + admin consent, a client secret
Grab the three values for the k8s secret:
terraform output -raw client_id
terraform output -raw tenant_id
This is a PUBLIC client (device-code flow) — there is no client secret. Leave
CLIENT_SECRETempty insecret.yaml. Presenting a secret makes Entra reject the token refresh withAADSTS700025 "Client is public...".
Step 2 — Deploy the relay
cd ..
cp secret.example.yaml secret.yaml # paste CLIENT_ID / CLIENT_SECRET / TENANT_ID
kubectl apply -f namespace.yaml
kubectl apply -f secret.yaml -f pvc.yaml
kubectl apply -f deployment.yaml -f service.yaml
kubectl -n smtp-relay rollout status deploy/smtp-relay
At this point Postfix runs but has no token yet, so relaying fails until step 3.
Step 3 — Bootstrap the token (one-time, interactive device-code)
The image's sasl-xoauth2-tool needs the msal Python module, which isn't bundled.
Install it ephemerally (only needed for this one mint; the C++ SASL plugin refreshes
without it):
POD=$(kubectl get pod -n smtp-relay -l app=smtp-relay -o name | head -1 | cut -d/ -f2)
kubectl exec -n smtp-relay $POD -- sh -c 'python3 -m ensurepip >/dev/null 2>&1; python3 -m pip install -q msal'
Mint the token (env vars come from the pod's secret; CLIENT_SECRET is empty → public flow):
kubectl exec -n smtp-relay -it deploy/smtp-relay -- sh -c \
'sasl-xoauth2-tool get-token outlook /etc/tokens/[email protected] \
--client-id="$CLIENT_ID" --tenant="$TENANT_ID" --client-secret="$CLIENT_SECRET" --use-device-flow'
It prints a URL + code — open https://microsoft.com/devicelogin and sign in as the SENDER
mailbox [email protected] (NOT yourself/the admin — a token minted for the wrong user gives
535 5.7.3). If prompted for a client secret, press Enter. Then fix ownership so Postfix can
read/rewrite it:
kubectl exec -n smtp-relay $POD -- chown postfix:postfix /etc/tokens/[email protected]
Verify identity if unsure: decode the token and check upn == [email protected]. The token
persists on the PVC; sasl-xoauth2 refreshes it automatically thereafter.
Step 4 — Test
kubectl exec -n smtp-relay $POD -- sh -c \
'echo "Subject: relay test\n\nhello" | sendmail -f [email protected] [email protected]'
kubectl exec -n smtp-relay $POD -- tail -n 40 /var/log/maillog # look for "status=sent"
Step 5 — Point apps at it
- Authelia — replace the filesystem notifier with SMTP in
../authelia/values.yaml: addresssmtp://smtp-relay.smtp-relay.svc.cluster.local:25, sender[email protected],disable_require_tls: true(plain in-cluster hop). 2FA enrollment codes then go to real email. - Any future app: same address, From =
[email protected](O365 rejects other senders with5.7.60unless a send-as alias is configured in Exchange).
Deliverability (keep mail out of Junk)
- SPF / MX / DMARC for
ddupan.topalready exist (M365 domain setup). - DKIM: ✅ enabled 2026-07-28 (
Enabled: True,Status: Valid). CNAMEs are in../../infrastructure/cloudflared/terraform/(selector1/2._domainkey); signing was turned on withscripts/enable-dkim.ps1thenscripts/enable-dkim-finish.ps1.- ⚠️ The CNAME target is NXDOMAIN until signing is enabled. Microsoft creates the
tenant host (
<tenant>.d-v1.dkim.mail.microsoft) only at enable time, so a correct CNAME looks broken beforehand andGet-DkimSigningConfigreportsCnameMissing. Do not go hunting for the "real" CNAME value — run step 2 and re-check DNS. - ⚠️ Both scripts deadlock if run via
!or with output redirected to a file — the device code never becomes visible. Run under a PTY:DOTNET_SYSTEM_NET_DISABLEIPV6=1 script -qfc "pwsh -NoProfile -File scripts/enable-dkim.ps1" /tmp/dkim.logTheDISABLEIPV6is required on the laptop — see the IPv6 trap in the rootCLAUDE.md; without itConnect-ExchangeOnlinehangs inSYN-SENTwith no output at all.
- ⚠️ The CNAME target is NXDOMAIN until signing is enabled. Microsoft creates the
tenant host (
- Still soft: SPF is
~alland DMARC isp=none. Harden to-all/p=quarantineonce aggregate reports confirm DKIM passes — not before, or you quarantine your own mail.
Notes / gotchas
- PUBLIC client, no secret — see Step 1.
CLIENT_SECRETstays empty. - Sign in as
noreply@(the sender), not the admin, during the Step 3 device login — a token minted for the wrong identity fails with535 5.7.3. msalis ephemeral — reinstall it in the pod (Step 3) before any re-mint; it's gone after a restart but only the one-time mint needs it.- Token PVC is writable state, not in Git. The refresh token rotates; it lives only on the PVC. Back it up if you want to avoid re-running step 3.
- Refresh-token longevity: Azure AD refresh tokens renew on use but can expire under Conditional Access / long idle — if relaying suddenly fails auth, re-run step 3.