Email relay
This summary page explains what the email relay stack is, how the series fits together, and how to get started.
Email relay series
- Email relay - You are here
- Architecture
- Manifests
- Flux integration
- Operations
What this is
A shared Kubernetes-based email relay infrastructure that provides MX domain validation, email logging, and Microsoft 365 Graph API delivery. Applications in the cluster send email through this relay instead of directly to external SMTP servers.
The problem I solved
Self-hosted scheduling applications attract spam bookings with fake email addresses. Someone could flood my calendar with appointments using spammer@nonexistent-domain.xyz and I would never know until the confirmation emails silently failed.
Microsoft Security Defaults also blocks legacy SMTP AUTH, so I needed a relay that uses modern authentication (Graph API) anyway.
Instead of solving these separately, I built a single relay that validates recipient domains have MX records before any email enters the system, logs all emails for debugging, and delivers via Microsoft 365 Graph API.
Components
| Component | Version | Port | Purpose |
|---|---|---|---|
| mx-validator | latest | 25 | SMTP proxy with MX validation + ICS stripping |
| Mailpit | v1.28.3 | 1025, 8025 | SMTP logging, web UI for debugging |
| smtp2graph | v1.1.4 | 2525 | Microsoft 365 Graph API delivery |
All three containers run in a single pod, communicating via localhost.
Architecture overview
Protection provided
| Attack | Protection | Result |
|---|---|---|
| Fake domain bookings | MX record validation | Rejected at SMTP level |
Typo domains (gmial.com) | MX record validation | Rejected (no MX) |
| Bounce storms | Early rejection | No emails sent |
| Resource exhaustion | Cached MX lookups, early reject | Minimal resources used |
| Sender reputation damage | Invalid emails never sent | M365 reputation preserved |
| Microsoft Graph ICS routing bug | ICS calendar stripping | Emails routed correctly |
Repository structure
The stack spans two repositories following the standard GitOps pattern.
App repo
Contains the Kubernetes manifests for the email-relay deployment.
your-org/email-relay/
├── .sops.yaml # SOPS encryption config
└── k8s/prod/
├── kustomization.yaml
├── 00-namespace.yaml
├── 10-secret-mailpit-auth.enc.yaml
├── 15-secret-smtp2graph-config.enc.yaml
├── 20-deployment.yaml # 3-container pod
├── 25-pvc-mailpit.yaml # Persistent storage for email logs
├── 30-service.yaml
├── 40-networkpolicy.yaml
├── 50-servicemonitor.yaml
└── 80-prometheusrule.yaml
Flux config repo
Contains the Flux objects that deploy the app repo.
clusters/my-cluster/email-relay/
├── kustomization.yaml
├── source.yaml # GitRepository
├── 00-kustomization-ns.yaml
├── 10-kustomization-app.yaml
└── prod/
├── kustomization.yaml
└── image-automation.yaml # Auto-update mx-validator
Quick start
After deploying via Flux:
# Check Flux status
flux get kustomizations -n flux-system | grep email-relay
# Check pods
kubectl get pods -n email-relay
# View mx-validator logs
kubectl logs -n email-relay -l app=email-relay -c mx-validator -f
# View Mailpit UI (all logged emails)
kubectl port-forward -n email-relay svc/email-relay 8025:8025
# Open http://localhost:8025
# Check Prometheus metrics
kubectl exec -n email-relay -l app=email-relay -c mx-validator -- \
wget -q -O- http://localhost:9090/metrics | grep mx_validator
Using the relay from applications
Applications connect to the relay using the internal service name:
env:
- name: EMAIL_SERVER_HOST
value: "email-relay.email-relay.svc.cluster.local"
- name: EMAIL_SERVER_PORT
value: "25"
See Cal.com scheduling for a complete integration example.
What you will learn
- Architecture: how the three containers work together, email flow, and attack scenarios protected
- Manifests: the mx-validator Go code, Dockerfile, and Kubernetes manifests
- Flux integration: GitOps deployment with image automation for the custom mx-validator
- Operations: Azure App Registration, smtp2graph config, Prometheus metrics, and troubleshooting