Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
137 lines
6.5 KiB
Markdown
137 lines
6.5 KiB
Markdown
# 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]`.
|
|
|
|
[`mauroreggio/postfix-365`]: https://github.com/mauroreggio/postfix-365
|
|
[`sasl-xoauth2`]: https://github.com/tarickb/sasl-xoauth2
|
|
|
|
---
|
|
|
|
## Prerequisites (Microsoft 365 / Entra — mostly interactive, one-time)
|
|
|
|
1. **`ddupan.top` is 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/`).
|
|
2. **Mailbox `[email protected]`** exists with an **Exchange Online license**.
|
|
3. **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)
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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_SECRET` empty in `secret.yaml`. Presenting a secret makes Entra reject the token
|
|
> refresh with `AADSTS700025 "Client is public..."`.
|
|
|
|
## Step 2 — Deploy the relay
|
|
|
|
```bash
|
|
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):
|
|
|
|
```bash
|
|
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):
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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`:
|
|
address `smtp://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
|
|
with `5.7.60` unless a send-as alias is configured in Exchange).
|
|
|
|
## Deliverability (keep mail out of Junk)
|
|
|
|
- **SPF / MX / DMARC** for `ddupan.top` already 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 with
|
|
`scripts/enable-dkim.ps1` then `scripts/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 and `Get-DkimSigningConfig` reports `CnameMissing`.
|
|
**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.log`
|
|
The `DISABLEIPV6` is required on the laptop — see the IPv6 trap in the root `CLAUDE.md`;
|
|
without it `Connect-ExchangeOnline` hangs in `SYN-SENT` with no output at all.
|
|
- **Still soft**: SPF is `~all` and DMARC is `p=none`. Harden to `-all` / `p=quarantine`
|
|
once aggregate reports confirm DKIM passes — not before, or you quarantine your own mail.
|
|
|
|
## Notes / gotchas
|
|
|
|
- **PUBLIC client, no secret** — see Step 1. `CLIENT_SECRET` stays empty.
|
|
- **Sign in as `noreply@` (the sender), not the admin**, during the Step 3 device login — a
|
|
token minted for the wrong identity fails with `535 5.7.3`.
|
|
- **`msal` is 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.
|